Compare commits

..

94 Commits

Author SHA1 Message Date
82f08c1684 feat(pokemon): remove manual sorting and enforce ID-based order
Remove pokemon.order permission and related API endpoints
Update queries to sort Pokemon by internal ID ascending
Replace reorderable list with standard list in Admin view
2026-05-06 22:35:46 +08:00
df78685dc3 feat(frontend): enhance trading item search and keyboard navigation
Implement weighted search scoring for trading items
Add keyboard support (arrows, enter) for item selection
Limit trading detail list height with independent scrolling
2026-05-06 22:11:09 +08:00
cc440ea949 feat(frontend): replace native confirms and enhance form controls
Add ConfirmDialog to replace window.confirm for delete actions
Enhance SwitchGroup with grid layout, descriptions, and disabled state
Update AdminView to use TagsSelect and SwitchGroup for better UX
2026-05-06 21:14:47 +08:00
5ef1f4ecc9 refactor(frontend): move detail view state initialization to server plugin
Remove top-level await from useAsyncData in detail views
Remove manual state initialization blocks in components
Introduce 03-detail-seo.server.ts to handle SEO and state
2026-05-06 17:40:44 +08:00
4dc73d42cb fix(frontend): await useAsyncData and initialize state in detail views
Restore await for useAsyncData to ensure data is fetched during SSR
Assign initial data to local refs to prevent empty states on load
2026-05-06 17:26:49 +08:00
fa656a8d02 refactor(auth): migrate fully to HTTP-only cookie sessions
Remove client-side token storage and Authorization header injection
Backend login now only returns user data, omitting the session token
Remove Authorization from backend CORS allowed headers
Clean up obsolete VITE_* environment variable fallbacks
Update Modal component to use Vue useId() instead of Math.random()
2026-05-06 17:15:46 +08:00
f26cfdc830 refactor(frontend): remove top-level await from useAsyncData
Transition to non-blocking data fetching to prevent navigation delays.
Initial data is now applied via immediate watchers instead of blocking setup.
2026-05-06 16:35:03 +08:00
71b35b9cc6 chore(docker): add local debug compose setup and scripts
Add docker-compose.debug.yml for local hot-reload debugging
Add docker:debug and docker:prod scripts to package.json
Update documentation and environment examples for debug usage
Update pnpm version in packageManager field
2026-05-06 16:18:23 +08:00
70f7a73e6d fix(frontend): safely resolve route IDs and remove manual auth checks
Prevent invalid API calls during route transitions in detail views
Allow builds for esbuild and @parcel/watcher in pnpm workspace
2026-05-06 15:59:36 +08:00
f92e97b747 feat(ssr): load initial data and SEO for public detail pages
Fetch initial content server-side for detail views and Life feed.
Bind detail-specific SEO head tags during SSR.
Extract resolvedSeoHead to share head tag generation.
2026-05-06 12:01:00 +08:00
d66124862a feat(ssr): load initial data for remaining public routes
Use useAsyncData to fetch initial list pages and options server-side
Apply SSR loading to Habitats, Items, Artifacts, Recipes, Dishes, and Home
2026-05-06 11:21:00 +08:00
f7986ca520 feat(seo): centralize route metadata and expand sitemap coverage
Remove static fallback tags from Nuxt config to prevent duplication.
Auto-apply noindex to authenticated and permissioned routes.
Add home, project updates, and legal pages to sitemap.
Properly escape JSON-LD structured data.
2026-05-06 11:01:19 +08:00
425f2f4d5f feat(ssr): load Pokemon lists and forward auth cookies on server
Update auth middleware to pass incoming request cookies to api.me()
Refactor API service to support custom headers via ApiRequestOptions
Use useAsyncData in PokemonList to fetch initial data during SSR
Ensure graceful fallback to client-side fetching on SSR failure
2026-05-06 10:50:51 +08:00
35ee164794 feat(frontend): enable Nuxt SSR and migrate to Nitro server
Set `ssr: true` in Nuxt config and switch build command to `nuxt build`.
Update Dockerfile to run `.output/server/index.mjs` and remove static server.
Defer SEO initialization to prevent premature evaluation during SSR.
2026-05-06 10:28:12 +08:00
cf1eb6965e refactor(i18n): isolate Vue I18n instances per request for SSR
Replace global I18n singleton with a factory function
Inject request-specific I18n instances into Nuxt app and SEO metadata
Prevent cross-request locale state pollution during server-side rendering
2026-05-06 10:10:07 +08:00
337a6bda1f refactor(seo): migrate metadata handling to Nuxt useHead
Remove direct document.head mutations to support SSR compatibility
Implement observer pattern to sync SEO state with Nuxt universal plugin
Update analytics script to use declarative injection in Nuxt config
2026-05-06 09:59:38 +08:00
fd1f3ef636 feat(auth): implement hybrid session model with HTTP-only cookies
Add HTTP-only cookie session support to backend for SSR compatibility
Update frontend fetch calls to include credentials
Maintain legacy bearer token support for SPA transition
2026-05-06 09:48:18 +08:00
afed409127 feat(frontend): support separate browser and server API base URLs
Add NUXT_SERVER_API_BASE_URL for internal server-side API requests
Update API and i18n services to select base URL by execution context
2026-05-06 09:31:11 +08:00
6e8edbbb09 refactor(frontend): migrate from Vite to Nuxt SPA
Replace Vite and Vue Router with Nuxt framework
Update Docker, build scripts, and env vars for Nuxt generate
2026-05-06 09:19:23 +08:00
c821e9ebba feat: implement infinite scrolling for public entity lists
Add cursor-based pagination to backend list queries
Introduce LoadMoreSentinel for intersection-based loading
Replace manual load more buttons with infinite scroll sentinel
2026-05-06 08:33:08 +08:00
91a001e3f9 feat(admin): add habitats CSV import to data tools
Support importing habitats from CSV files to batch create entries
Add validation, API endpoint, and admin UI for the import process
2026-05-06 07:06:08 +08:00
22016365d8 feat: add pokemon trading preferences and item tag inference
Introduce trading preference (Likes/Neutral) for Pokemon with trading skills
Infer possible hidden tags for items based on trading observations
Update import/export, wipe, and admin config to support trading data
2026-05-05 22:54:32 +08:00
5b22d788d7 feat(admin): add items CSV import to data tools
Allow bulk importing items via CSV in the admin data tools
Support static image paths for items imported from CSV
2026-05-05 17:51:38 +08:00
0e2743b469 chore(db): clean up redundant schema migrations and legacy import logic
Remove obsolete ALTER TABLE statements and data migration blocks that are already reflected in base
table definitions.
Simplify data tool import normalization by removing legacy artifact mapping and unused entity types.
2026-05-05 11:51:08 +08:00
5a83a73108 refactor(items): merge ancient artifacts into items data model
Migrate ancient artifacts to items table using a category key.
Consolidate detail and edit views into ItemDetail and ItemEdit.
Update API, search, and data tools to reflect unified structure.
2026-05-05 10:50:07 +08:00
839a24566b refactor(items): improve edit form layout with responsive grid rows
Group related fields like name/price and category/usage.
Stack fields vertically on screens smaller than 720px.
2026-05-05 09:05:53 +08:00
9312156a3c feat(items): add base price and support usage in creation defaults
Add `base_price` to items schema, API, and edit history
Display and edit base price in item details and forms
Add `clearable` prop to TagsSelect for optional single selections
Include usage in item creation session defaults
2026-05-05 08:59:36 +08:00
8ee29e9549 feat(history): exclude sort order changes from edit history
Stop recording sort order changes in the backend edit log
Filter out existing sort order changes from the frontend edit history panel
2026-05-05 07:15:18 +08:00
357dc061d6 feat(items): support drag-and-drop reordering and contextual insert
Implement drag-and-drop sorting in the items grid
Add right-click context menu to insert new items before or after
Update backend to process insertion anchors during item creation
2026-05-05 07:01:21 +08:00
a17344d216 feat(ui): add session defaults menu for item creation
Support presetting category, checkboxes, and acquisition methods.
Persist defaults in sessionStorage to streamline repetitive data entry.
2026-05-04 22:45:32 +08:00
cd0f8868c3 feat(ui): implement compact grid layout for items and artifacts
Add compact tooltip mode to EntityCard component
Display 12-column icon grid on desktop for collections
Retain standard card layout with details on mobile devices
2026-05-04 22:19:36 +08:00
28f4e6032c refactor: remove display ID from items and ancient artifacts
Drop display_id column from items and ancient_artifacts tables
Remove display ID inputs, labels, and sorting logic across the stack

BREAKING CHANGE: behavior is not backward compatible.
2026-05-04 21:32:00 +08:00
2220d5d595 feat(dish): add dish management and public view
Add database schema, permissions, and API endpoints for dishes
Implement frontend views and admin management for dish data
2026-05-04 21:00:23 +08:00
2ff2519647 feat(comments): add sorting and liking functionality
Support sorting by oldest, latest, most-liked, and most-replied.
Implement like/unlike actions for Life and Entity Discussion comments.
2026-05-04 17:29:09 +08:00
504849c14a feat(search): include user profiles in global search results
Add users group to global search API and frontend types
Query users by display name and link to their public profiles
Update system wordings for the new search group
2026-05-04 16:04:58 +08:00
8cb8190554 feat(users): implement user following system and following feed
Add follow/unfollow actions and social stats to user profiles
Introduce Following feed scope in Life view
Add notifications for new followers
2026-05-04 15:49:57 +08:00
016364a8b8 feat(life): allow authors to view and restore their deleted comments
Update backend to return soft-deleted comments to their authors
Add restore endpoint and frontend Undo button for deleted comments
Retain comment body and author information upon deletion
2026-05-04 14:54:00 +08:00
b0e2036965 feat(life): hide deleted comments and approved moderation status
Completely remove deleted comments and their replies from lists, previews, and counts.
Hide the "approved" moderation status badge to reduce visual clutter.
2026-05-04 14:53:45 +08:00
06e0cbb1c1 feat(search): add global search across wiki entities
Implement /api/search endpoint for cross-entity querying
Add GlobalSearch component to top navigation bar with categorized results
2026-05-04 14:20:12 +08:00
3dd3998a5c feat(ui): introduce global top navigation bar for user actions
Relocate language, notifications, profile, and auth controls from the sidebar to a new sticky
topbar.
Update AppShell grid layout and responsive styles to accommodate the topbar across breakpoints.
2026-05-04 13:08:23 +08:00
bd944556d9 feat(ui): refine mobile layouts and add 430px breakpoint
Adjust component dimensions, spacing, and typography for 640px screens
Introduce 430px media query to optimize layouts for smaller devices
2026-05-04 11:54:16 +08:00
07698e063d feat(moderation): add user-facing reasons for rejected or failed content
Prompt AI models to provide short explanations for rejected content
Store reasons in database and broadcast via WebSocket
Display moderation details in UI for authors and admins
2026-05-04 11:18:54 +08:00
3d6188748d feat(moderation): add real-time status updates via WebSocket
Broadcast moderation status changes to the author via WebSocket
Update UI in real-time for Life Posts, Comments, and Discussions
Hide retry moderation button while status is reviewing
2026-05-04 10:54:21 +08:00
a25f1661b5 feat(notifications): add real-time notification system
Add database tables for notifications and WebSocket tickets
Implement REST API and WebSocket server for real-time delivery
Add NotificationBell component with dropdown and unread badge
Trigger alerts for comments, reactions, and AI moderation results
2026-05-04 10:40:14 +08:00
579d092020 feat(life): add Life Post reaction users modal and API
Add GET /api/life-posts/:id/reactions endpoint with pagination
Add LifeReactionUsersModal to view and filter reaction users
Make reaction summaries clickable in feeds, details, and profiles
2026-05-04 10:10:38 +08:00
7ff7e18b94 feat(life): add Life Post detail page and endpoint
Implement GET /api/life-posts/:id with moderation and visibility rules
Add /life/:id route and LifePostDetail view
Update feeds and user profiles to link to the new detail page
2026-05-04 09:51:31 +08:00
bcff83a512 feat(gateway): add nginx gateway for maintenance mode fallback
Proxy frontend traffic through Nginx to handle service restarts gracefully
Serve a static 503 maintenance page when frontend or backend is unavailable
Update deployment design docs and docker-compose configuration
2026-05-04 09:12:39 +08:00
03f5735bd2 feat(nav): add collapsible sidebar and nested navigation groups
Refactor sidebar to support nested navigation groups for related entities.
Implement collapsible desktop sidebar with icon tooltips.
2026-05-04 08:57:31 +08:00
4238be7761 feat: add ancient artifacts and refactor item categories
Introduce Ancient Artifacts with full CRUD and image support
Migrate item categories and usages to system-defined lists
Add display_id to items and artifacts for custom sorting
2026-05-04 08:28:56 +08:00
5ccc25b248 feat: separate regular and event entities for Pokemon and Habitats
Add dedicated routes and navigation for Event Pokemon and Event Habitats
Update API endpoints to filter by isEventItem and adapt frontend views
2026-05-04 06:50:37 +08:00
f2a8b67ebf feat(admin): add data tools for export, import, and wipe
Add admin.data.export and admin.data.import permissions
Implement backend logic and API endpoints for data bundle management
Add Data Tools tab to admin interface with scope selection
Support Pokemon, Habitats, Items, Recipes, and Daily CheckList scopes
2026-05-04 00:56:37 +08:00
fa06d24826 feat(pokemon): store official data identity separate from display ID
Add data_id and data_identifier to pokemon schema
Use official data ID as internal route ID for non-event pokemon
Prevent applying fetched data with mismatched ID to existing pokemon
2026-05-04 00:06:22 +08:00
8dfd03f3d2 feat: add project updates feed and dedicated page
Proxy and sanitize Gitea repository data via /api/project-updates
Display recent commits and releases preview on the Home page
Add /project-updates route for paginated commit history
2026-05-03 23:40:34 +08:00
a0e07f101a perf(pokemon): cache fetch options locally to reduce API requests
Add `all` parameter to fetch-options API to retrieve the full list.
Fetch all options once and filter locally in the Pokemon edit view to improve search responsiveness.
2026-05-03 22:34:49 +08:00
df212a4e27 feat(pokemon): decouple official data ID from display ID during fetch
Allow fetching data and images using official identifiers regardless of the custom display ID.
Extract data ID directly from image paths instead of relying on the display ID.
Only auto-fill display ID from fetched data if the field is currently empty.
2026-05-03 22:23:29 +08:00
deb0b54e71 feat(admin): make user rate limits configurable via admin UI
Add rate_limit_settings table and corresponding admin permissions
Replace static user rate limits with dynamic in-memory counters
Add interface in admin panel to configure rate limit policies
2026-05-03 22:11:41 +08:00
b0e2464c24 feat(auth): implement Resend email quota and rate limit protection
Track Resend API usage via response headers to prevent quota exhaustion
Block auth requests with 503 when email delivery limits are reached
2026-05-03 19:42:41 +08:00
40f85ae85c feat(auth): implement branded HTML templates for auth emails
Add a standardized HTML shell for verification and password reset emails.
Update system wordings with new email copy, buttons, and fallback links.
Strip standalone action links from content to use styled buttons.
2026-05-03 19:33:25 +08:00
3a8a61487a feat(config): support multiple CORS origins and dynamic docker env vars
Parse comma-separated origins in FRONTEND_ORIGIN for Fastify CORS
Use host environment variables with fallbacks in docker-compose
Add Cloudflared tunnel deployment examples to .env.example
2026-05-03 19:22:38 +08:00
72ddae6f9d chore: add Umami analytics and adjust docker-compose ports
Inject Umami analytics script into frontend index.html
Map backend port to 20016 and remove exposed postgres port
2026-05-03 19:14:57 +08:00
fcb9b57aa3 feat(history): track translation and config changes in edit history
Record localized field updates across all translatable entities.
Track sort order modifications and specific config properties.
Support parsing and translating localized field labels in the UI.
2026-05-03 19:09:49 +08:00
d80c9325cd refactor(life): extract rating control and reorganize post actions
Extract 5-star rating UI into a dedicated LifeRatingControl component
Move moderation status and retry button into the engagement actions bar
2026-05-03 19:06:02 +08:00
105274eec8 feat(life): add game versions and 5-star ratings to posts
Support associating life posts with specific game versions
Allow 1-5 star ratings on posts in rateable categories
Add feed filters for game version, rateable status, and top-rated sorting
2026-05-03 18:38:33 +08:00
4ebb45aa94 feat(legal): add legal pages and global footer
Introduce Privacy Policy, Terms of Service, and Disclaimers views
Add site footer with copyright, legal links, and attribution notices
Update system wordings with comprehensive legal content in EN/ZH
2026-05-03 18:04:33 +08:00
6758aaaa7e feat(home): add home page as main entry point
Introduce HomeView with quick links to wiki sections and community features
Update navigation, routing, and logo links to point to the new home page
2026-05-03 17:46:36 +08:00
6782ddd101 feat(life): replace multiple tags with single category for posts
Add default category support and enforce one category per Life Post
Update UI filters, forms, and translations to reflect category semantics
2026-05-03 17:34:32 +08:00
18baf7b513 feat(moderation): add AI moderation for user-generated content
Add AI moderation settings, caching, and status tracking
Require AI approval for Life Posts, Comments, and Discussions
Implement language filtering and moderation status UI
Add retry mechanism for failed moderation checks
2026-05-03 17:08:51 +08:00
590bd6a0ae build: optimize Dockerfiles for production and pin dependencies
Implement multi-stage build and static server for frontend
Run containers as non-root user and set production environment
Pin all package dependencies to exact versions
2026-05-03 15:35:00 +08:00
7aa80430d9 refactor(api): remove internal metadata from image upload responses
Omit entity details, original filename, MIME type, and file size from payloads
Update backend SQL queries and frontend interfaces to align with design specs
2026-05-03 15:24:27 +08:00
960898c858 feat(comments): paginate life post and entity discussion comments
Implement cursor-based pagination for Life and Entity comments
Optimize Life Post queries to return comment counts and previews
Add "Load more" functionality to frontend discussion panels
2026-05-03 15:20:05 +08:00
0c76d6bfc8 feat(api): implement rate limiting for abuse prevention
Add @fastify/rate-limit with granular policies for different routes
Support TRUST_PROXY environment variable for reverse proxies
2026-05-03 15:04:07 +08:00
8f55db9061 feat(auth): enforce role level boundaries and owner assignment rules
Add `admin.users.assign-owner` permission to control Owner role assignment.
Restrict role assignment to roles strictly below the assigner's highest level.
2026-05-03 14:50:52 +08:00
1dab650c2c feat(seo): implement dynamic metadata, sitemap, and robots.txt
Add dynamic meta tags for routes and entity detail pages
Generate sitemap.xml and robots.txt dynamically in Vite
Change default frontend port from 3000 to 20015
2026-05-03 14:31:22 +08:00
282481bbcc feat(profile): add password change and activity filters
Implement password change API and UI in the Account tab
Add secondary filters for contributions, reactions, and comments
Display referral summary in the profile header
2026-05-03 13:52:35 +08:00
0e835f9c03 feat(profile): add public user profiles with activity tabs and stats
Add API routes for user stats, posts, reactions, and comments
Implement profile view with Feeds, Contributions, Reactions tabs
Link to user profiles from edit history, discussions, and life posts
Add database indexes to optimize user-centric queries
2026-05-03 13:14:29 +08:00
b9ec8076ac feat(auth): assign default editor role to verified users without roles
Update bootstrap rules to grant 'editor' role to verified users
Backfill existing verified users without roles in schema.sql
Apply default role automatically during email verification
2026-05-03 12:41:00 +08:00
043ebe392a refactor(backend): localize validation errors and consolidate schema
Replace hardcoded validation error messages with i18n keys.
Merge ALTER TABLE statements into initial CREATE TABLE definitions.
Clean up obsolete data migration scripts from schema file.
2026-05-03 12:16:26 +08:00
ef82fc805d feat(automation): add coming soon page and navigation entry
Add Automation route and navigation item with in-development badge
Include localized wordings, icon, and design docs for the new section
2026-05-03 12:05:29 +08:00
95d76522df feat(life): require at least one tag for life posts
Update design spec to mandate tag selection
Add frontend and backend validation for tag requirement
Add localization strings for tag required errors
2026-05-03 11:47:08 +08:00
accd6f98cf fix(ui): prevent dropdown clipping with fixed positioning
Automatically use fixed dropdown strategy for TagsSelect inside modals
Dynamically calculate fixed coordinates for Pokemon fetch results dropdown
2026-05-03 11:38:32 +08:00
3ca66d7124 docs(agents): add git diff hygiene rules for CSV files
Instruct agents to ignore data/**/*.csv files in git diffs by default.
Prevents false positives caused by WSL file system quirks.
2026-05-03 11:30:55 +08:00
8bc311916d feat(admin): redesign navigation with grouped secondary sidebar
Replace flat tabs with categorized navigation groups (Content, Config, etc.).
Update layout styles to support a responsive secondary sidebar.
2026-05-03 11:27:43 +08:00
05f531ddf2 feat(auth): implement role-based access control (RBAC)
Add roles, permissions, and user_roles tables with default seed data
Protect backend API endpoints with granular permission checks
Add admin UI for managing users, roles, and permissions
Update frontend views to conditionally render actions based on permissions
2026-05-03 11:16:58 +08:00
05898f9441 feat(auth): add user referral system with invite codes
Generate unique referral codes for users
Allow new users to register with a referral code
Display referral stats and invite link in user profile
2026-05-03 10:27:45 +08:00
3d99f00c75 feat(wiki): add event item flag and decouple pokemon display ID
Add `is_event_item` to pokemon, items, and habitats.
Separate internal `id` and `display_id` for pokemon to allow event variants.
Update frontend forms and views to support the new fields.
2026-05-03 10:11:04 +08:00
4d05618530 feat: add images and profile grid layout to entity detail pages
Return image data for related entities across all backend detail queries
Display images or default placeholders in detail headers, chips, and lists
Standardize Item, Recipe, and Habitat detail views with a new profile grid
2026-05-03 09:51:45 +08:00
784cbdacd1 feat(wiki): add community image upload for wiki entities
Support uploading images for Pokemon, Items, and Habitats
Track upload history in new entity_image_uploads table
Update entity cards to display uploaded images and usage ribbons
2026-05-03 01:08:45 +08:00
36e10a06b0 feat(auth): add user profile page and display name update
Add PATCH /api/auth/me endpoint to update user display name
Create UserProfileView for managing account details and email status
Update AppShell sidebar to link authenticated user to profile page
2026-05-02 22:38:33 +08:00
4a42756e2e feat(auth): add password reset and remember me options
Add password reset request and reset endpoints with email verification
Add "Remember me" option to login for persistent sessions
Create frontend views for forgot and reset password flows
2026-05-02 22:13:10 +08:00
97f06794a8 refactor(pokemon): simplify list card to display only image and name
Remove environment, types, skills, and meta from the list view
Update CSS and skeleton loaders for a centered, larger image layout
2026-05-02 21:37:09 +08:00
874ecc5625 feat(pokemon): redesign image display with side thumbnail and modal
Move image thumbnail to the right of base stats
Display detailed image information in a modal
2026-05-02 21:15:48 +08:00
cf0ae566c0 feat(pokemon): add image selection and display from pokesprite
Add image metadata fields to Pokemon schema and API
Implement image candidate fetching from pokesprite static tree
Add Pokédex-style image picker to edit form and display in details
2026-05-02 20:59:33 +08:00
475e3577dd refactor(admin): redesign system wording layout with sidebar and tabs
Replace module and surface dropdowns with a sidebar and tabbed interface.
Improve navigation and usability in the admin wording section.
2026-05-02 11:57:59 +08:00
976a2a2482 feat(i18n): implement dynamic system wording management
Add database schema and API endpoints for system wording keys and values
Replace hardcoded translations in frontend and backend with dynamic messages
Add System Wordings management interface to Admin view
2026-05-02 11:48:11 +08:00
146 changed files with 103605 additions and 3234 deletions

View File

@@ -3,4 +3,3 @@
**/dist
**/*.log
**/.env
frontend

View File

@@ -3,8 +3,31 @@ POSTGRES_USER=pokopia
POSTGRES_PASSWORD=pokopia
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
BACKEND_PORT=3001
FRONTEND_ORIGIN=http://localhost:3000
APP_ORIGIN=http://localhost:3000
VITE_API_BASE_URL=http://localhost:3001
TRUST_PROXY=false
FRONTEND_ORIGIN=http://localhost:20015
APP_ORIGIN=http://localhost:20015
BACKEND_PUBLIC_ORIGIN=http://localhost:20016
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=
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
View File

@@ -1,6 +1,8 @@
node_modules/
.pnpm-store/
dist/
.nuxt/
.output/
.env
.env.*
!.env.example

1
.repomixignore Normal file
View File

@@ -0,0 +1 @@
data/**/*.csv

View File

@@ -15,11 +15,12 @@
For any non-trivial task:
1. **Read `DESIGN.md`**
2. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`**
3. **Produce a short plan (no code)**
4. Wait for approval
5. Implement in small steps
6. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
2. While `SSR_MIGRATION_TASKLIST.md` exists, **also read `SSR_MIGRATION_TASKLIST.md`** and keep SSR migration work aligned with it.
3. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`**
4. **Produce a short plan (no code)**
5. Wait for approval
6. Implement in small steps
7. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
Do NOT skip planning.
@@ -27,6 +28,16 @@ For documentation-only tasks, still follow the planning workflow, but do not run
---
## 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.
@@ -34,8 +45,8 @@ For documentation-only tasks, still follow the planning workflow, but do not run
* Runtime baseline: Node.js >= 22.
* Frontend:
* Nuxt SPA mode currently (`ssr: false`), with SSR migration tracked in `SSR_MIGRATION_TASKLIST.md`
* Vue
* Vite
* Vue Router
* Vue I18n
* Iconify
@@ -128,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)
User-facing UI must NEVER contain:

838
DESIGN.md

File diff suppressed because it is too large Load Diff

60
SSR_MIGRATION_TASKLIST.md Normal file
View 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`.

View File

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

File diff suppressed because it is too large Load Diff

View File

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

1099
backend/src/aiModeration.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View 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
View 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]
);
}

View File

@@ -10,5 +10,5 @@
"forceConsistentCasingInFileNames": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
"include": ["src/**/*.ts", "tests/**/*.ts", "../system-wordings.ts"]
}

108
docker-compose.debug.yml Normal file
View 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:

View File

@@ -5,8 +5,6 @@ services:
POSTGRES_DB: pokopia
POSTGRES_USER: pokopia
POSTGRES_PASSWORD: pokopia
ports:
- "5432:5432"
volumes:
- postgres18_data:/var/lib/postgresql
healthcheck:
@@ -22,25 +20,49 @@ services:
environment:
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
BACKEND_PORT: 3001
FRONTEND_ORIGIN: http://localhost:3000
APP_ORIGIN: http://localhost:3000
TRUST_PROXY: ${TRUST_PROXY:-false}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:20015}
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:20015}
UPLOAD_DIR: /app/uploads
BACKEND_PUBLIC_ORIGIN: ${BACKEND_PUBLIC_ORIGIN:-http://localhost:20016}
RESEND_API_KEY: ${RESEND_API_KEY:-}
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
ports:
- "3001:3001"
- "20016:3001"
volumes:
- backend_uploads:/app/uploads
depends_on:
postgres:
condition: service_healthy
frontend:
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:
VITE_API_BASE_URL: http://localhost:3001
ports:
- "3000:3000"
PORT: 20015
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}
expose:
- "20015"
depends_on:
- 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:
postgres18_data:
backend_uploads:

View File

@@ -1,8 +1,30 @@
FROM node:22-alpine
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json ./
RUN corepack enable && pnpm install
COPY . .
EXPOSE 3000
CMD ["pnpm", "run", "dev"]
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json ./backend/package.json
COPY frontend/package.json ./frontend/package.json
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install --frozen-lockfile --filter @pokopia/frontend...
COPY frontend ./frontend
COPY system-wordings.ts ./system-wordings.ts
ARG 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
View 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>

View 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 };
}
};

View 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>

View 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;
}
}

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<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>

View 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
View 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']
}
}
});

View File

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

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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);
});

View 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
}
};
});

View 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);
});

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -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));
});

View 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));
});

View 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>
`;
}

View File

@@ -1,127 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import AppShell from './components/AppShell.vue';
import {
iconAction,
iconAdmin,
iconChecklist,
iconClothes,
iconDish,
iconDreamIsland,
iconEvent,
iconHabitat,
iconItem,
iconLife,
iconPokemon,
iconRecipe
} from './icons';
import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
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;
function inDevBadge() {
return { label: t('common.inDev'), tone: 'info' as const };
}
const navItems = computed(() => [
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
{ label: t('nav.items'), to: '/items', icon: iconItem },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
{ label: t('nav.life'), to: '/life', icon: iconLife },
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin }
]);
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');
}
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');
}
} catch {
// Keep the built-in language list when the API is not ready yet.
}
}
function updateLocale(value: string) {
setCurrentLocale(value);
}
onMounted(() => {
void loadLanguages();
void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => {
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"
>
<RouterView :key="locale" />
</AppShell>
</template>

View File

@@ -3,24 +3,56 @@ import { Icon } from '@iconify/vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { iconClose, iconLogin, iconLogout, iconMenu, iconRegister, iconTranslate, type AppIcon } from '../icons';
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 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<{
currentUser: AuthUser | null;
languages: Language[];
locale: string;
navItems: Array<{
label: string;
to: string;
icon?: AppIcon;
badge?: {
label: string;
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
};
}>;
navItems: NavItem[];
}>();
const emit = defineEmits<{
@@ -30,27 +62,64 @@ const emit = defineEmits<{
const { t } = useI18n();
const route = useRoute();
const copyrightYear = new Date().getFullYear();
const languageMenu = ref<HTMLElement | null>(null);
const languageMenuButton = ref<HTMLButtonElement | null>(null);
const 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) {
@@ -78,27 +147,111 @@ function requestLogout() {
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>
<template>
<div class="app-shell" :class="{ 'app-shell--sidebar-open': sidebarOpen }">
<header class="mobile-topbar">
<div
class="app-shell"
:class="{
'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"
@@ -110,49 +263,20 @@ onBeforeUnmount(() => {
<Icon :icon="sidebarOpen ? iconClose : iconMenu" class="ui-icon" aria-hidden="true" />
</button>
<RouterLink class="brand-lockup brand-lockup--mobile" to="/pokemon" aria-label="Pokopia Wiki" @click="closeSidebar">
<RouterLink class="brand-lockup brand-lockup--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>
</header>
</div>
<button class="site-sidebar-scrim" type="button" :aria-label="t('nav.closeMenu')" @click="closeSidebar"></button>
<GlobalSearch class="site-topbar__search" @navigate="closeSidebar" />
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">
<div class="site-sidebar__inner">
<RouterLink class="brand-lockup" to="/pokemon" aria-label="Pokopia Wiki" @click="closeSidebar">
<PokeBallMark size="42px" />
<span>
<span class="pokemon-word">Pokopia</span>
<span class="brand-subtitle">Community Wiki</span>
</span>
</RouterLink>
<div class="site-topbar__spacer" aria-hidden="true"></div>
<nav class="side-nav" :aria-label="t('nav.main')">
<RouterLink
v-for="item in navItems"
:key="item.to"
class="side-nav__link"
:class="{ 'router-link-active': isNavActive(item.to) }"
:to="item.to"
@click="closeSidebar"
>
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
<span class="side-nav__label">{{ item.label }}</span>
<StatusBadge
v-if="item.badge"
class="side-nav__badge"
:label="item.badge.label"
:tone="item.badge.tone"
compact
/>
</RouterLink>
</nav>
<div class="auth-actions">
<div class="topbar-actions">
<div ref="languageMenu" class="language-menu" @keydown="onLanguageMenuKeydown">
<button
ref="languageMenuButton"
@@ -164,7 +288,6 @@ onBeforeUnmount(() => {
@click="toggleLanguageMenu"
>
<Icon :icon="iconTranslate" class="language-menu__icon" aria-hidden="true" />
<span class="language-menu__glyph" aria-hidden="true">/A</span>
</button>
<div v-if="languageMenuOpen" class="language-menu__dropdown" role="menu">
@@ -183,29 +306,183 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<template v-if="currentUser">
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
<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" />
{{ t('nav.logout') }}
</button>
</template>
<template v-else>
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login" @click="closeSidebar">
<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" />
{{ t('nav.login') }}
</RouterLink>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register" @click="closeSidebar">
<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" />
{{ t('nav.register') }}
</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" />
<span>
<span class="pokemon-word">Pokopia</span>
<span class="brand-subtitle">Community Wiki</span>
</span>
</RouterLink>
<button
class="sidebar-collapse-toggle"
type="button"
:aria-label="sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')"
: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>
<nav ref="sideNav" class="side-nav" :aria-label="t('nav.main')" @scroll="updateSidebarTooltipPosition">
<template v-for="item in navItems" :key="navItemKey(item)">
<div v-if="isNavGroup(item)" class="side-nav__group" :class="{ 'side-nav__group--active': isNavGroupActive(item) }">
<button
class="side-nav__link side-nav__group-trigger"
:class="{ 'router-link-active': isNavGroupActive(item) }"
type="button"
:aria-expanded="isNavGroupExpanded(item)"
: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>
<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">
<slot></slot>
</main>
<footer class="site-footer">
<div class="site-footer__inner">
<p class="site-footer__copyright">
{{ t('legal.footer.copyright', { year: copyrightYear }) }}
</p>
<nav class="site-footer__links" :aria-label="t('legal.footer.linksLabel')">
<RouterLink to="/privacy-policy" @click="closeSidebar">{{ t('legal.footer.privacy') }}</RouterLink>
<RouterLink to="/terms-of-service" @click="closeSidebar">{{ t('legal.footer.terms') }}</RouterLink>
<RouterLink to="/disclaimers" @click="closeSidebar">{{ t('legal.footer.disclaimers') }}</RouterLink>
</nav>
<p class="site-footer__notice">{{ t('legal.footer.notice') }}</p>
</div>
</footer>
</div>
</template>

View File

@@ -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>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import EditMeta from './EditMeta.vue';
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
defineProps<{
const props = defineProps<{
entity: EditInfo;
history: EditHistoryEntry[];
}>();
@@ -12,9 +13,19 @@ 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',
@@ -35,10 +46,15 @@ const changeLabelKeys: Record<string, string> = {
'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',
@@ -58,7 +74,18 @@ const changeLabelKeys: Record<string, string> = {
Item: 'pages.recipes.item',
物品: 'pages.recipes.item',
Materials: 'pages.recipes.materials',
需要材料: 'pages.recipes.materials'
需要材料: 'pages.recipes.materials',
'Sort order': 'pages.admin.sortOrder',
排序: 'pages.admin.sortOrder',
'Has item drop': 'pages.admin.hasItemDrop',
有掉落物: 'pages.admin.hasItemDrop',
'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 {
@@ -74,6 +101,14 @@ function actionMark(action: EditHistoryAction): string {
}
function changeLabel(label: string): string {
const localizedFieldMatch = label.match(/^(.+) \(([^()]+)\)$/);
if (localizedFieldMatch) {
const [, fieldLabel, languageCode] = localizedFieldMatch;
if (fieldLabel && languageCode) {
return `${changeLabel(fieldLabel)} (${languageCode})`;
}
}
const key = changeLabelKeys[label];
return key ? t(key) : label;
}
@@ -90,12 +125,21 @@ function changeValue(value: string): string {
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 {
if (!entry.changes.length) {
const changes = visibleChanges(entry);
if (!changes.length) {
return actionLabel(entry.action);
}
return entry.changes.map((change) => changeLabel(change.label)).join(locale.value === 'zh-CN' ? '、' : ', ');
return changes.map((change) => changeLabel(change.label)).join(locale.value === 'zh-CN' ? '、' : ', ');
}
function formatDateTime(value: string): string {
@@ -116,23 +160,25 @@ function formatDateTime(value: string): string {
<div>
<dt>{{ t('history.createdBy') }}</dt>
<dd>
<strong>{{ displayName(entity.createdBy) }}</strong>
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
<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>
<strong>{{ displayName(entity.updatedBy) }}</strong>
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
<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="history.length" class="edit-timeline">
<li v-for="entry in history" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
<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">
@@ -141,8 +187,8 @@ function formatDateTime(value: string): string {
</summary>
<div class="edit-history-entry__content">
<dl v-if="entry.changes.length" class="edit-change-list">
<div v-for="change in entry.changes" :key="`${change.label}-${change.before}-${change.after}`">
<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>
@@ -156,7 +202,12 @@ function formatDateTime(value: string): string {
<dl class="edit-history-detail-meta">
<div>
<dt>{{ t('history.author') }}</dt>
<dd>{{ displayName(entry.user) }}</dd>
<dd>
<RouterLink v-if="entry.user" class="user-profile-link" :to="`/profile/${entry.user.id}`">
{{ entry.user.displayName }}
</RouterLink>
<span v-else>{{ displayName(entry.user) }}</span>
</dd>
</div>
<div>
<dt>{{ t('history.time') }}</dt>

View File

@@ -2,9 +2,15 @@
import { useI18n } from 'vue-i18n';
import type { EditInfo } from '../services/api';
defineProps<{
withDefaults(
defineProps<{
entity: EditInfo;
}>();
showLabel?: boolean;
}>(),
{
showLabel: true
}
);
const { locale, t } = useI18n();
@@ -18,6 +24,11 @@ function formatDateTime(value: string): string {
<template>
<p class="edit-meta">
{{ t('history.lastEdited') }}: {{ entity.updatedBy?.displayName ?? t('common.system') }} / {{ 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>
</template>

View File

@@ -9,31 +9,52 @@ defineProps<{
to?: string;
icon?: AppIcon;
marker?: string;
image?: { src: string; alt: string };
ribbon?: string;
compactTooltip?: boolean;
}>();
</script>
<template>
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
<span class="entity-card__mark">
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
<RouterLink
v-if="to"
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>
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
<div class="entity-card__content">
<span class="entity-card__title">{{ title }}</span>
<slot name="after-title"></slot>
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
<slot></slot>
</div>
</RouterLink>
<article v-else class="entity-card">
<span class="entity-card__mark">
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
<article v-else class="entity-card" :class="{ 'entity-card--collection-compact': compactTooltip }">
<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>
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
<div class="entity-card__content">
<span class="entity-card__title">{{ title }}</span>
<slot name="after-title"></slot>
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
<slot></slot>
</div>

View File

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

View File

@@ -2,15 +2,22 @@
import { Icon } from '@iconify/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { iconCancel, iconComment, iconDelete, iconReply } from '../icons';
import 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,
getAuthToken,
onAuthTokenChange,
setAuthToken,
moderationUpdateEvent,
onAuthChange,
type AiModerationStatus,
type AuthUser,
type CommentSort,
type DiscussionEntityType,
type EntityDiscussionComment
type EntityDiscussionComment,
type Language,
type ModerationUpdateDetail
} from '../services/api';
import Skeleton from './Skeleton.vue';
@@ -21,8 +28,10 @@ const props = defineProps<{
const { locale, t } = useI18n();
const comments = ref<EntityDiscussionComment[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const loadingMore = ref(false);
const authReady = ref(false);
const body = ref('');
const replyBodies = ref<Record<number, string>>({});
@@ -32,43 +41,97 @@ 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);
const canComment = computed(() => currentUser.value?.emailVerified === true);
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
}
const canComment = computed(() => can('discussions.comments.create'));
const canLikeComments = computed(() => can('discussions.comments.like'));
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const commentTotal = computed(() => comments.value.reduce((total, comment) => total + 1 + comment.replies.length, 0));
const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
const languageTabs = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('discussion.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name }))
]);
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;
if (!getAuthToken()) {
currentUser.value = null;
authReady.value = true;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
} finally {
authReady.value = true;
}
}
async function loadDiscussion() {
async function loadLanguages() {
try {
languages.value = (await api.languages()).filter((language) => language.enabled);
if (
activeLanguageCode.value !== allLanguageValue &&
!languages.value.some((language) => language.code === activeLanguageCode.value)
) {
activeLanguageCode.value = allLanguageValue;
}
} catch {
languages.value = [];
}
}
function mergeComments(existing: EntityDiscussionComment[], incoming: EntityDiscussionComment[]) {
const ids = new Set(existing.map((comment) => comment.id));
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
}
async function loadDiscussion(reset = true) {
if (!reset && (loadingMore.value || !hasMoreComments.value)) {
return;
}
const nextRequestId = ++requestId;
if (reset) {
loading.value = true;
} else {
loadingMore.value = true;
}
loadError.value = '';
try {
const rows = await api.entityDiscussion(props.entityType, props.entityId);
const page = await api.entityDiscussion(props.entityType, props.entityId, {
limit: discussionPageSize,
cursor: reset ? null : nextCursor.value,
language: selectedLanguageCode.value,
sort: activeSort.value
});
if (nextRequestId === requestId) {
comments.value = rows;
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
nextCursor.value = page.nextCursor;
hasMoreComments.value = page.hasMore;
commentTotal.value = page.total;
}
} catch (error) {
if (nextRequestId === requestId) {
@@ -77,6 +140,7 @@ async function loadDiscussion() {
} finally {
if (nextRequestId === requestId) {
loading.value = false;
loadingMore.value = false;
}
}
}
@@ -93,6 +157,17 @@ 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] ?? '';
}
@@ -108,7 +183,59 @@ function clearCommentError(key: string) {
}
function canManageComment(comment: EntityDiscussionComment) {
return !comment.deleted && currentUser.value?.id === comment.author?.id;
return (
!comment.deleted &&
((currentUser.value?.id === comment.author?.id && can('discussions.comments.delete')) ||
can('discussions.comments.delete-any'))
);
}
function canSeeModeration(comment: EntityDiscussionComment) {
return currentUser.value?.id === comment.author?.id || can('discussions.comments.delete-any');
}
function canRetryModeration(comment: EntityDiscussionComment) {
return !comment.deleted && comment.moderationStatus !== 'approved' && 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) {
@@ -158,9 +285,16 @@ async function submitComment() {
formError.value = '';
try {
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, {
body: nextBody,
languageCode: selectedLanguageCode.value ?? null
});
comments.value = [...comments.value, comment];
commentTotal.value += 1;
body.value = '';
if (activeSort.value !== 'oldest') {
void loadDiscussion();
}
} catch (error) {
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
} finally {
@@ -180,9 +314,17 @@ async function submitReply(comment: EntityDiscussionComment) {
clearCommentError(key);
try {
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, {
body: nextBody,
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
});
comment.replies.push(reply);
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 {
@@ -190,6 +332,122 @@ async function submitReply(comment: EntityDiscussionComment) {
}
}
async function retryModeration(comment: EntityDiscussionComment) {
const key = commentKey(comment.id);
moderationBusyId.value = comment.id;
clearCommentError(key);
try {
const updated = await api.retryEntityDiscussionModeration(comment.id);
comment.moderationStatus = updated.moderationStatus;
comment.moderationLanguageCode = updated.moderationLanguageCode;
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) {
@@ -207,11 +465,34 @@ function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolea
return false;
}
async function deleteComment(comment: EntityDiscussionComment) {
if (!window.confirm(t('discussion.deleteConfirm'))) {
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);
@@ -231,19 +512,33 @@ watch(
() => {
resetComposer();
comments.value = [];
nextCursor.value = null;
hasMoreComments.value = false;
commentTotal.value = 0;
void loadDiscussion();
}
);
onMounted(() => {
void loadCurrentUser();
watch(activeLanguageCode, () => {
comments.value = [];
nextCursor.value = null;
hasMoreComments.value = false;
commentTotal.value = 0;
void loadDiscussion();
removeAuthListener = onAuthTokenChange(() => {
});
onMounted(() => {
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void loadCurrentUser();
void loadLanguages();
void loadDiscussion();
removeAuthListener = onAuthChange(() => {
void loadCurrentUser();
});
});
onUnmounted(() => {
window.removeEventListener(moderationUpdateEvent, handleModerationUpdate);
removeAuthListener?.();
});
</script>
@@ -257,6 +552,16 @@ onUnmounted(() => {
</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>
@@ -310,12 +615,37 @@ onUnmounted(() => {
<div class="entity-discussion-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
<div class="entity-discussion-comment__content">
<div class="entity-discussion-comment__meta">
<strong>{{ commentAuthorName(comment) }}</strong>
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
{{ comment.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(comment) }}</strong>
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
<StatusBadge
v-if="canSeeModeration(comment)"
:label="moderationLabel(comment.moderationStatus)"
:tone="moderationTone(comment.moderationStatus)"
compact
/>
</div>
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
<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"
@@ -326,18 +656,34 @@ onUnmounted(() => {
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.reply') }}</span>
</button>
<button
v-if="canRetryModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('discussion.moderationRetry')"
:disabled="moderationBusyId === comment.id"
@click="retryModeration(comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ moderationBusyId === comment.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
</span>
</button>
<button
v-if="canManageComment(comment)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('discussion.deleteComment')"
@click="deleteComment(comment)"
@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>
@@ -382,21 +728,63 @@ onUnmounted(() => {
<div class="entity-discussion-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
<div class="entity-discussion-comment__content">
<div class="entity-discussion-comment__meta">
<strong>{{ commentAuthorName(reply) }}</strong>
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
{{ reply.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(reply) }}</strong>
<time :datetime="reply.createdAt">{{ formatDateTime(reply.createdAt) }}</time>
<StatusBadge
v-if="canSeeModeration(reply)"
:label="moderationLabel(reply.moderationStatus)"
:tone="moderationTone(reply.moderationStatus)"
compact
/>
</div>
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
<div v-if="canManageComment(reply)" class="entity-discussion-comment__actions">
<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="deleteComment(reply)"
@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>
@@ -405,6 +793,8 @@ onUnmounted(() => {
</div>
</div>
</article>
<LoadMoreSentinel :active="hasMoreComments" :disabled="loading || loadingMore" @load="loadDiscussion(false)" />
</div>
<div v-else class="entity-discussion-empty">
@@ -414,5 +804,17 @@ onUnmounted(() => {
<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>

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { iconClose, iconSearch } from '../icons';
import { api, type GlobalSearchGroup, type GlobalSearchGroupType, type GlobalSearchItem } from '../services/api';
const emit = defineEmits<{
navigate: [];
}>();
const { t } = useI18n();
const router = useRouter();
const root = ref<HTMLElement | null>(null);
const input = ref<HTMLInputElement | null>(null);
const query = ref('');
const groups = ref<GlobalSearchGroup[]>([]);
const open = ref(false);
const mobileOpen = ref(false);
const loading = ref(false);
const failed = ref(false);
let searchTimeout: number | null = null;
let abortController: AbortController | null = null;
let requestId = 0;
const cleanQuery = computed(() => query.value.trim());
const hasResults = computed(() => groups.value.some((group) => group.items.length > 0));
const firstResult = computed(() => groups.value.find((group) => group.items.length > 0)?.items[0] ?? null);
const panelVisible = computed(() => open.value && cleanQuery.value !== '' && (loading.value || failed.value || groups.value.length > 0));
const groupLabels: Record<GlobalSearchGroupType, string> = {
pokemon: 'search.groups.pokemon',
habitats: 'search.groups.habitats',
items: 'search.groups.items',
'ancient-artifacts': 'search.groups.ancientArtifacts',
recipes: 'search.groups.recipes',
'daily-checklist': 'search.groups.dailyChecklist',
life: 'search.groups.life',
users: 'search.groups.users'
};
function clearSearchTimeout() {
if (searchTimeout !== null) {
window.clearTimeout(searchTimeout);
searchTimeout = null;
}
}
function abortSearch() {
abortController?.abort();
abortController = null;
}
function resetResults() {
groups.value = [];
failed.value = false;
loading.value = false;
}
async function runSearch(value: string) {
const currentRequestId = ++requestId;
abortSearch();
const controller = new AbortController();
abortController = controller;
loading.value = true;
failed.value = false;
try {
const response = await api.globalSearch(value, controller.signal);
if (currentRequestId === requestId) {
groups.value = response.groups;
}
} catch (error) {
if (controller.signal.aborted) {
return;
}
if (currentRequestId === requestId) {
groups.value = [];
failed.value = true;
}
} finally {
if (currentRequestId === requestId) {
loading.value = false;
if (abortController === controller) {
abortController = null;
}
}
}
}
function scheduleSearch() {
clearSearchTimeout();
const value = cleanQuery.value;
if (!value) {
requestId += 1;
abortSearch();
resetResults();
return;
}
requestId += 1;
abortSearch();
loading.value = true;
failed.value = false;
searchTimeout = window.setTimeout(() => {
searchTimeout = null;
void runSearch(value);
}, 240);
}
function openPanel() {
open.value = true;
}
function closePanel() {
open.value = false;
}
function toggleMobileSearch() {
mobileOpen.value = !mobileOpen.value;
openPanel();
if (mobileOpen.value) {
void nextTick(() => input.value?.focus());
}
}
function clearQuery() {
query.value = '';
resetResults();
openPanel();
void nextTick(() => input.value?.focus());
}
function onSubmit() {
const item = firstResult.value;
if (!item) {
openPanel();
return;
}
void navigateTo(item);
}
async function navigateTo(item: GlobalSearchItem) {
selectResult();
await router.push(item.url);
}
function selectResult() {
closePanel();
mobileOpen.value = false;
emit('navigate');
}
function onRootKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
closePanel();
input.value?.blur();
}
}
function onDocumentPointerDown(event: PointerEvent) {
if (root.value && !root.value.contains(event.target as Node)) {
closePanel();
}
}
function groupLabel(type: GlobalSearchGroupType) {
return t(groupLabels[type]);
}
watch(query, scheduleSearch);
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown);
});
onBeforeUnmount(() => {
clearSearchTimeout();
abortSearch();
document.removeEventListener('pointerdown', onDocumentPointerDown);
});
</script>
<template>
<div
ref="root"
class="global-search"
:class="{ 'global-search--mobile-open': mobileOpen }"
@keydown="onRootKeydown"
>
<button
class="global-search__toggle"
type="button"
:aria-label="t('search.open')"
:aria-expanded="mobileOpen"
@click="toggleMobileSearch"
>
<Icon :icon="mobileOpen ? iconClose : iconSearch" class="ui-icon" aria-hidden="true" />
</button>
<form class="global-search__form" role="search" @submit.prevent="onSubmit">
<Icon :icon="iconSearch" class="ui-icon global-search__form-icon" aria-hidden="true" />
<input
ref="input"
v-model="query"
class="global-search__input"
type="search"
:placeholder="t('search.placeholder')"
:aria-label="t('search.label')"
:aria-controls="panelVisible ? 'global-search-results' : undefined"
:aria-expanded="panelVisible"
autocomplete="off"
@focus="openPanel"
/>
<button
v-if="cleanQuery"
class="global-search__clear"
type="button"
:aria-label="t('search.clear')"
@click="clearQuery"
>
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
</button>
</form>
<div
v-if="panelVisible"
id="global-search-results"
class="global-search__panel"
:aria-busy="loading"
>
<div v-if="loading" class="global-search__skeleton" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<p v-else-if="failed" class="global-search__message">{{ t('search.failed') }}</p>
<p v-else-if="!hasResults" class="global-search__message">{{ t('search.empty') }}</p>
<template v-else>
<section
v-for="group in groups"
:key="group.type"
class="global-search__group"
:aria-label="groupLabel(group.type)"
>
<h2 class="global-search__group-title">{{ groupLabel(group.type) }}</h2>
<RouterLink
v-for="item in group.items"
:key="`${group.type}-${item.id}`"
class="global-search__result"
:to="item.url"
@click="selectResult"
>
<img
v-if="item.image"
class="global-search__result-image"
:src="item.image.url"
:alt="item.title"
loading="lazy"
/>
<span v-else class="global-search__result-mark" aria-hidden="true">
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
</span>
<span class="global-search__result-copy">
<span class="global-search__result-title">{{ item.title }}</span>
<span v-if="item.summary || item.meta" class="global-search__result-meta">
<span v-if="item.meta">{{ item.meta }}</span>
<span v-if="item.summary">{{ item.summary }}</span>
</span>
</span>
</RouterLink>
</section>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { iconCancel, iconImage, iconUpload } from '../icons';
import { api, type EntityImage, type EntityImageUpload, type ImageUploadEntityType } from '../services/api';
const props = withDefaults(
defineProps<{
modelValue: string;
entityType: ImageUploadEntityType;
entityId?: string | number | null;
entityName: string;
label?: string;
currentImage?: EntityImage | null;
history?: EntityImageUpload[];
disabled?: boolean;
allowUpload?: boolean;
showPreview?: boolean;
}>(),
{
label: '',
currentImage: null,
history: () => [],
disabled: false,
allowUpload: true,
showPreview: true
}
);
const emit = defineEmits<{
'update:modelValue': [value: string];
uploaded: [image: EntityImageUpload];
selected: [image: EntityImage];
error: [message: string];
}>();
const { t } = useI18n();
const fileInput = ref<HTMLInputElement | null>(null);
const uploadBusy = ref(false);
const localUploads = ref<EntityImageUpload[]>([]);
const imageLabel = computed(() => props.label || t('media.image'));
const uploadDisabled = computed(() => !props.allowUpload || props.disabled || uploadBusy.value || props.entityName.trim() === '');
const imageOptions = computed<EntityImage[]>(() => {
const images = [
...localUploads.value,
...(props.history ?? []),
...(props.currentImage ? [props.currentImage] : [])
];
const seen = new Set<string>();
return images.filter((image) => {
if (!image.path || seen.has(image.path)) {
return false;
}
seen.add(image.path);
return true;
});
});
const selectedImage = computed(() => {
if (!props.modelValue) {
return null;
}
return imageOptions.value.find((image) => image.path === props.modelValue) ?? props.currentImage ?? null;
});
function imageName(image: EntityImage): string {
const parts = image.path.split('/');
return parts.at(-1) ?? t('media.image');
}
function openFilePicker() {
if (!uploadDisabled.value) {
fileInput.value?.click();
}
}
function selectImage(image: EntityImage) {
emit('update:modelValue', image.path);
emit('selected', image);
}
function clearImage() {
emit('update:modelValue', '');
}
async function uploadImage(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadBusy.value = true;
try {
const uploaded = await api.uploadImage(props.entityType, {
file,
entityName: props.entityName,
entityId: props.entityId
});
localUploads.value = [uploaded, ...localUploads.value];
emit('uploaded', uploaded);
selectImage(uploaded);
} catch (error) {
emit('error', error instanceof Error && error.message ? error.message : t('media.uploadFailed'));
} finally {
uploadBusy.value = false;
input.value = '';
}
}
</script>
<template>
<section class="image-upload-field field">
<div class="image-upload-field__header">
<span class="field-label">{{ imageLabel }}</span>
<div class="image-upload-field__actions">
<input
v-if="allowUpload"
ref="fileInput"
class="image-upload-field__input"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
:disabled="uploadDisabled"
@change="uploadImage"
/>
<button v-if="allowUpload" type="button" class="ui-button ui-button--blue ui-button--small" :disabled="uploadDisabled" @click="openFilePicker">
<Icon :icon="iconUpload" class="ui-icon" aria-hidden="true" />
{{ uploadBusy ? t('media.uploading') : t('media.uploadImage') }}
</button>
<button v-if="modelValue" type="button" class="plain-button ui-button--small" :disabled="disabled || uploadBusy" @click="clearImage">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('media.clearImage') }}
</button>
</div>
</div>
<div v-if="showPreview && selectedImage" class="pokemon-image-preview image-upload-field__preview" :aria-label="t('media.selectedImage')">
<div class="pokemon-image-preview__screen">
<img :src="selectedImage.url" :alt="t('media.imageAlt', { name: entityName })" />
</div>
<div class="pokemon-image-preview__caption">
<strong>{{ t('media.selectedImage') }}</strong>
<span>{{ imageName(selectedImage) }}</span>
</div>
</div>
<p v-else-if="showPreview" class="meta-line">{{ t('media.imageEmpty') }}</p>
<div v-if="imageOptions.length" class="pokemon-image-thumbnails image-upload-field__history" :aria-label="t('media.imageHistory')">
<button
v-for="image in imageOptions"
:key="image.path"
type="button"
class="pokemon-image-thumbnail"
:class="{ active: image.path === modelValue }"
:aria-pressed="image.path === modelValue"
:disabled="disabled || uploadBusy"
@click="selectImage(image)"
>
<img :src="image.url" :alt="t('media.imageAlt', { name: entityName })" loading="lazy" />
<span>{{ imageName(image) }}</span>
</button>
</div>
<p v-else class="meta-line image-upload-field__empty">
<Icon :icon="iconImage" class="ui-icon" aria-hidden="true" />
{{ t('media.imageHistoryEmpty') }}
</p>
</section>
</template>

View File

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

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
iconReactionFun,
iconReactionHelpful,
iconReactionLike,
iconReactionThanks
} from '../icons';
import {
api,
type LifeReactionType,
type LifeReactionUser
} from '../services/api';
import Modal from './Modal.vue';
import Skeleton from './Skeleton.vue';
import Tabs, { type TabOption } from './Tabs.vue';
type ReactionFilter = LifeReactionType | 'all';
const props = defineProps<{
postId: number;
initialReactionType?: LifeReactionType | null;
}>();
const emit = defineEmits<{
close: [];
}>();
const { locale, t } = useI18n();
const reactionUsers = ref<LifeReactionUser[]>([]);
const nextCursor = ref<string | null>(null);
const hasMore = ref(false);
const total = ref(0);
const loading = ref(false);
const loadingMore = ref(false);
const loadError = ref('');
const activeReactionType = ref<ReactionFilter>(props.initialReactionType ?? 'all');
const pageSize = 20;
const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
{ type: 'fun', icon: iconReactionFun, labelKey: 'pages.life.reactionFun' },
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
const reactionTabs = computed<TabOption[]>(() => [
{ value: 'all', label: t('pages.life.allReactions') },
...reactionOptions.map((option) => ({ value: option.type, label: reactionLabel(option.type) }))
]);
function reactionLabel(type: LifeReactionType) {
return t(reactionOptions.find((option) => option.type === type)?.labelKey ?? 'pages.life.react');
}
function reactionIcon(type: LifeReactionType) {
return reactionOptions.find((option) => option.type === type)?.icon ?? iconReactionLike;
}
function selectedReactionType() {
return activeReactionType.value === 'all' ? undefined : activeReactionType.value;
}
function formatReactedAt(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);
}
async function loadReactionUsers(reset = false) {
if (loading.value || loadingMore.value || (!reset && !hasMore.value)) {
return;
}
const cursor = reset ? null : nextCursor.value;
loading.value = reset;
loadingMore.value = !reset;
loadError.value = '';
if (reset) {
reactionUsers.value = [];
nextCursor.value = null;
hasMore.value = false;
total.value = 0;
}
try {
const page = await api.lifeReactionUsers(props.postId, {
cursor,
limit: pageSize,
reactionType: selectedReactionType()
});
reactionUsers.value = reset ? page.items : [...reactionUsers.value, ...page.items];
nextCursor.value = page.nextCursor;
hasMore.value = page.hasMore;
total.value = page.total;
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
} finally {
loading.value = false;
loadingMore.value = false;
}
}
watch(
() => props.initialReactionType,
(nextReactionType) => {
activeReactionType.value = nextReactionType ?? 'all';
}
);
watch(
[() => props.postId, activeReactionType, locale],
() => {
void loadReactionUsers(true);
},
{ immediate: true }
);
</script>
<template>
<Modal :title="t('pages.life.reactionUsersTitle')" :subtitle="t('pages.life.reactionUsersSubtitle')" :close-label="t('common.close')" @close="emit('close')">
<div class="life-reaction-users-modal">
<Tabs id="life-reaction-users-filter" v-model="activeReactionType" :tabs="reactionTabs" :label="t('pages.life.reactionFiltersLabel')" />
<p class="life-reaction-users-modal__count">{{ t('pages.life.reactionsCount', { count: total }) }}</p>
<p v-if="loadError" class="life-form__error" role="alert">{{ loadError }}</p>
<div v-if="loading" class="life-reaction-user-list" aria-hidden="true">
<article v-for="index in 4" :key="index" class="life-reaction-user">
<Skeleton variant="box" width="38px" height="38px" />
<div class="life-reaction-user__copy">
<Skeleton width="140px" />
<Skeleton width="190px" />
</div>
</article>
</div>
<div v-else-if="reactionUsers.length" class="life-reaction-user-list">
<article v-for="item in reactionUsers" :key="`${item.user.id}-${item.reactedAt}`" class="life-reaction-user">
<RouterLink class="life-reaction-user__avatar" :to="`/profile/${item.user.id}`" :aria-label="item.user.displayName">
{{ item.user.displayName.slice(0, 1).toUpperCase() || '#' }}
</RouterLink>
<div class="life-reaction-user__copy">
<RouterLink class="user-profile-link" :to="`/profile/${item.user.id}`">
{{ item.user.displayName }}
</RouterLink>
<span>
<Icon :icon="reactionIcon(item.reactionType)" class="ui-icon" aria-hidden="true" />
{{ reactionLabel(item.reactionType) }}
<time :datetime="item.reactedAt">{{ formatReactedAt(item.reactedAt) }}</time>
</span>
</div>
</article>
</div>
<div v-else class="life-reaction-users-empty">
<Icon :icon="iconReactionLike" class="life-reaction-users-empty__icon" aria-hidden="true" />
<h3>{{ t('pages.life.reactionUsersEmpty') }}</h3>
</div>
<div v-if="hasMore && !loading" class="life-feed__retry">
<button class="ui-button ui-button--ghost ui-button--small" type="button" :disabled="loadingMore" @click="loadReactionUsers(false)">
{{ loadingMore ? t('common.loading') : t('pages.life.loadMoreReactions') }}
</button>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
active: boolean;
disabled?: boolean;
rootMargin?: string;
}>(),
{
disabled: false,
rootMargin: '360px 0px'
}
);
const emit = defineEmits<{
load: [];
}>();
const sentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
function disconnectObserver() {
observer?.disconnect();
observer = null;
}
function observeSentinel() {
disconnectObserver();
if (!props.active || props.disabled || !sentinel.value) {
return;
}
if (typeof IntersectionObserver === 'undefined') {
emit('load');
return;
}
observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
emit('load');
}
},
{ rootMargin: props.rootMargin }
);
observer.observe(sentinel.value);
}
onMounted(() => {
void nextTick(observeSentinel);
});
onBeforeUnmount(disconnectObserver);
watch(
() => [props.active, props.disabled, props.rootMargin, sentinel.value],
() => {
void nextTick(observeSentinel);
},
{ flush: 'post' }
);
</script>
<template>
<div v-if="active" ref="sentinel" class="load-more-sentinel" aria-hidden="true"></div>
</template>

Some files were not shown because too many files have changed in this diff Show More