Compare commits

...

6 Commits

Author SHA1 Message Date
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
78 changed files with 7413 additions and 836 deletions

View File

@@ -7,6 +7,9 @@ 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
VITE_API_BASE_URL=http://localhost:20016
VITE_SITE_URL=https://pokopiawiki.tootaio.com
RESEND_API_KEY=
@@ -21,4 +24,7 @@ AI_MODERATION_API_KEY=
# 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
# VITE_API_BASE_URL=https://api-pokopiawiki.tootaio.com

2
.gitignore vendored
View File

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

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

View File

@@ -15,7 +15,7 @@
## 技术栈
- Monorepopnpm workspaceNode.js >= 22TypeScript。
- 前端:Vue、Vite、Vue Router、Vue I18n、Iconify。
- 前端:Nuxt`ssr: true`、Vue、Vue Router、Vue I18n、Iconify。
- 后端Node.js、Fastify、pg、PostgreSQL。
- 运维Docker / docker compose。
- 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。
@@ -33,6 +33,7 @@
- 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。
- 前端当前语言保存在 `localStorage``pokopia_locale`
- Nuxt SSR 运行时每个 Nuxt app/request 创建独立 Vue I18n 实例,避免跨请求共享 locale 或系统文案状态;服务端默认使用 `en`,客户端 hydration 后按 `pokopia_locale` 恢复用户语言。
- 后端默认语言为 `en`
- 语言配置存储在 `languages`
- `code`
@@ -120,10 +121,14 @@
- 重置 token 只保存 hash并带过期时间和使用状态。
- 密码重置成功后不自动登录,并删除该用户已有 session。
- 登录页提供 Remember me
- 未勾选时前端将登录 token 保存在 `sessionStorage``pokopia_auth_token`,服务端 session 有效期为 1 天。
- 勾选时前端将登录 token 保存在 `localStorage``pokopia_auth_token`,服务端 session 有效期为 30 天。
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
- 用户可退出登录,退出时删除对应 session
- 未勾选时 session 有效期为 1 天。
- 勾选时 session 有效期为 30 天。
- SSR 迁移期认证使用 hybrid session model
- 登录成功后后端设置 HTTP-only `pokopia_session` cookiecookie 只保存明文 session token数据库只保存 session token hash
- 迁移期登录响应仍返回明文 session token前端继续按 Remember me 语义保存到 `sessionStorage``localStorage``pokopia_auth_token`,用于保持现有 SPA 客户端流程兼容。
- 受保护 API 优先接受 HTTP-only cookie session并继续兼容 `Authorization: Bearer` legacy token。
- 前端 API 请求携带 credentials以便浏览器自动发送 HTTP-only session cookieJavaScript 不读取该 cookie。
- 用户可退出登录,退出时删除对应 session、清除 HTTP-only session cookie并清理前端 legacy token storage。
- 对外用户字段只包含必要信息:
- 当前用户:`id``email``displayName``emailVerified`
- 编辑署名:`id``displayName`
@@ -1036,8 +1041,8 @@ API 暴露边界:
- `favicon.ico`
- 默认社交分享图
- 品牌 Logo 素材
- `VITE_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`
- 前端入口 `index.html` 提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon客户端路由切换后根据当前路由更新页面 metadata
- `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`Nuxt 配置仍兼容读取旧的 `VITE_SITE_URL` 作为 fallback。
- 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata避免直接操作 `document.head`
- 主要公开浏览入口可索引:
- `/pokemon`
- `/event-pokemon`
@@ -1061,6 +1066,9 @@ API 暴露边界:
## 部署与升级维护
- Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。
- 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供Nuxt 配置仍兼容读取旧的 `VITE_API_BASE_URL` 作为 fallback。
- Nuxt 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL`
- 前端 Docker 构建使用 Nuxt server output`frontend` 服务通过 Node 运行 `.output/server/index.mjs`Nuxt SSR server 监听容器内 `0.0.0.0:20015`,公开流量仍由 `frontend_gateway` 代理。
- `frontend``docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。
- 升级维护页是基础设施级静态 fallback不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。
- 升级维护页使用 `503``Retry-After: 300``Cache-Control: no-store``noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。

145
SSR_MIGRATION_TASKLIST.md Normal file
View File

@@ -0,0 +1,145 @@
# SSR Migration Task List
This temporary task list tracks the work required to move the frontend from the current Nuxt SPA migration state to a complete SSR-capable Nuxt deployment.
Keep this file aligned with implementation progress while the SSR migration is in flight. When SSR migration is complete and validated, delete this file and remove the related temporary instruction from `AGENTS.md`.
## Target State
- [x] Nuxt runs with `ssr: true` for production.
- [ ] Public browsing routes render meaningful HTML on the server, including localized metadata and public business data where practical.
- [ ] Authenticated, management, edit, and modal workflows remain functionally equivalent to the current SPA behavior.
- [ ] No password hashes, session token hashes, verification/reset token hashes, role internals, permission internals, audit payloads, debug fields, or implementation notes are exposed through SSR payloads, API responses, generated HTML, metadata, logs, or UI.
- [ ] `DESIGN.md`, Docker configuration, environment variable documentation, and runtime behavior agree.
## Phase 1: Baseline Audit
- [x] Read `DESIGN.md`, `DesignGuidelines.html` when UI behavior is touched, `AGENTS.md`, and this task list before making SSR migration changes.
- [x] Inventory all browser-only access in `frontend/src`, `frontend/app.vue`, `frontend/plugins`, `frontend/middleware`, and `frontend/pages`: `window`, `document`, `localStorage`, `sessionStorage`, DOM measurement, event listeners, timers, clipboard, `confirm`, `matchMedia`, and direct head mutation.
- [x] Classify each browser-only usage as client-only component behavior, SSR-safe fallback behavior, or logic that must be moved into Nuxt composables/plugins.
- [x] Inventory route-level data loading across public list/detail pages, authenticated pages, management pages, and route-backed modal pages.
- [x] Identify public routes that should SSR first: Home, Pokemon/Event Pokemon lists, Habitat/Event Habitat lists, Items/Event Items lists, Ancient Artifacts, Recipes, Daily CheckList, Dish, Life list/detail, Project Updates, legal pages, and public Profile.
- [x] Identify routes that should remain client-only or mostly client-rendered initially: Login, Register, Forgot/Reset Password, Verify Email, Admin, `/profile`, notification UI, upload/edit forms, and route-backed edit/create modals.
### Phase 1 Audit Notes
- Browser-only access is concentrated in client interactions: modal focus/body locking, dropdown positioning, sidebar/mobile drawer behavior, search debounce, infinite-scroll sentinels, upload/download helpers, clipboard copy, local checklist state, route-backed form defaults, WebSocket notifications, moderation update events, and destructive-action `window.confirm` calls. These should stay in mounted/client-only lifecycle paths during SSR enablement.
- SSR-safe fallback candidates already guard storage or DOM access in `frontend/src/i18n.ts`, `frontend/src/services/api.ts`, and several views with `typeof window`, `typeof document`, `typeof localStorage`, `typeof sessionStorage`, or `typeof IntersectionObserver`.
- Logic that must move to SSR-aware Nuxt APIs in later phases: direct SEO mutation in `frontend/src/seo.ts` / `frontend/plugins/02-seo.client.ts`, global Vue I18n singleton request state, auth middleware's storage-only token model, and Nuxt config analytics script injection.
- Current route data loading is client-mounted in views rather than route-level `useAsyncData` / `useFetch`. Public list/detail candidates load through `api.*Page`, `api.*Detail`, `api.dish`, `api.dailyChecklistPage`, `api.lifePosts`, `api.lifePost`, `api.projectUpdates`, and public profile/activity endpoints. Auth, admin, edit/create modal, notification, upload, comment/reaction, and profile account flows depend on client auth state or browser APIs and should not be first-wave SSR data routes.
- First SSR data groups should be low-risk public reads: Home/project update preview, legal/static pages, Pokemon/Event Pokemon lists and details, Habitat/Event Habitat lists and details, Items/Event Items/Ancient Artifacts lists and details, Recipes list/details, Daily CheckList, Dish, Life public feed/detail, Project Updates, and public Profile.
## Phase 2: Runtime Config And API Layer
- [x] Replace client-only API base URL setup with an SSR-safe runtime config helper that works in server and client contexts.
- [x] Define separate public/browser API origin and internal server API origin if Docker networking requires different URLs for server-side fetches and browser fetches.
- [x] Ensure every server-side API read sends the correct `X-Locale` and never sends browser-only bearer tokens unless a secure SSR auth mechanism is implemented.
- [x] Add a small SSR-safe fetch wrapper or adapt `frontend/src/services/api.ts` so public reads can be called from server-side setup without depending on `window`, storage, or DOM APIs.
- [x] Keep frontend API response types consistent with `frontend/src/services/api.ts`.
- [ ] Ensure API errors used for SSR public routes degrade to intended empty/error states without leaking stack traces or internal fields into rendered HTML.
## Phase 3: Authentication And Session Model
- [x] Decide and document the SSR-compatible auth model in `DESIGN.md` before implementation.
- [x] Migrate auth from `localStorage` / `sessionStorage` bearer-token-only behavior to an HTTP-only cookie/session model, or explicitly document a hybrid model if Remember me must preserve current storage behavior.
- [x] Update backend login/logout/session endpoints to support the chosen cookie/session model without exposing session token hashes or internal session metadata.
- [x] Preserve Remember me semantics: 1 day for non-remembered sessions, 30 days for remembered sessions.
- [x] Preserve email verification as the base requirement for protected writes.
- [ ] Ensure current-user SSR reads expose only the allowed current-user fields defined in `DESIGN.md`.
- [ ] Update route middleware so server-side redirects for authenticated and permissioned routes match current client-side behavior.
- [ ] Ensure public SSR pages never render private current-user data into HTML meant for anonymous users.
- [x] Add a clear logout flow that clears both server cookies and any legacy client storage during the transition.
### Phase 3 Auth Notes
- The migration now uses a hybrid session model: backend login sets an HTTP-only `pokopia_session` cookie and still returns the legacy bearer token so existing SPA storage behavior keeps working during the transition.
- Protected backend reads and writes accept the HTTP-only cookie first and remain compatible with `Authorization: Bearer` tokens.
- Frontend API requests use `credentials: 'include'` so browser requests can carry the cookie without exposing it to JavaScript.
- Login still stores the legacy token according to Remember me semantics; logout deletes the server session, clears the cookie, and clears legacy frontend storage.
## Phase 4: Nuxt SSR Enablement
- [x] Change Nuxt config from `ssr: false` to `ssr: true` only after browser-only usage and auth strategy are ready.
- [ ] Split plugins by runtime where needed: `.client.ts` for DOM/event/storage logic, `.server.ts` for SSR-only initialization, and universal plugins only for code safe in both contexts.
- [x] Ensure Vue I18n is installed safely for SSR and does not share mutable per-request state across users.
- [x] Move direct `document.head` SEO mutation to Nuxt `useHead` / `useSeoMeta` or another SSR-aware head strategy.
- [x] Ensure route metadata remains the source for default SEO, required auth, required permission, editor modal behavior, and noindex rules.
- [ ] Confirm route-backed modal pages still preserve underlying page context and avoid unwanted scroll jumps.
- [ ] Keep UI business text localized through Vue I18n/system wordings; do not add implementation notes or debug text to the UI.
### Phase 4 SEO Notes
- `frontend/src/seo.ts` now resolves SEO state without mutating `document.head` or `document.title`.
- `frontend/plugins/02-seo.ts` is a universal Nuxt plugin that binds route metadata and client-side detail overrides to `useHead`.
- The Nuxt config analytics script is declarative and no longer injects a script with `document.head.appendChild`.
### Phase 4 I18n Notes
- `frontend/src/i18n.ts` now exports a Vue I18n factory instead of a module-level singleton.
- `frontend/plugins/01-i18n.ts` creates and installs one I18n instance per Nuxt app/request; only the browser instance is registered for legacy helpers that need localStorage and locale-change events.
- SEO route metadata translation uses the current Nuxt app's I18n translator instead of importing a shared global I18n instance.
### Phase 4 SSR Config Notes
- `frontend/nuxt.config.ts` now uses `ssr: true`.
- `pnpm --filter @pokopia/frontend build` completed with Nuxt SSR enabled and generated Nuxt server output at `.output/server/index.mjs`.
- Production container now targets the Nuxt server entry point; Docker runtime validation remains tracked in Phase 8.
## Phase 5: Server-Side Data And SEO
- [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages.
- [ ] For each SSR-enabled public route, render title, description, canonical URL, robots value, Open Graph, Twitter card, and structured data from public business data and system wording only.
- [ ] For detail pages, use entity names, public images, localized public fields, and canonical detail URLs after public API data loads server-side.
- [ ] Preserve `noindex` on auth, admin, new, edit, and in-development routes.
- [ ] Keep `robots.txt` and `sitemap.xml` generated from the same stable public route set documented in `DESIGN.md`.
- [ ] Avoid serializing private auth state, raw permissions, internal audit payloads, or unneeded API payload fields into Nuxt payloads.
- [ ] Confirm localized reads follow the fallback order in `DESIGN.md`: requested locale, default-language translation, base field.
## Phase 6: Browser-Only UI Isolation
- [ ] Move DOM event listeners, resize/scroll handlers, focus traps, modal body locking, clipboard behavior, and `window.confirm` calls into client-only lifecycle paths.
- [ ] Ensure components with DOM measurement render stable SSR placeholders or no-op behavior until mounted.
- [ ] Keep loading states as Skeleton loaders where existing page patterns support them.
- [ ] Validate that notification WebSocket setup only runs on the client and never during SSR.
- [ ] Validate upload widgets and file APIs only run on the client.
- [ ] Ensure route transitions and scroll behavior remain consistent with the current route-backed modal rules.
## Phase 7: Docker And Deployment
- [x] Update frontend Docker image from static `.output/public` serving to Nuxt server output when SSR is enabled.
- [ ] Run the production container with the Nuxt server entry point rather than the current static server.
- [x] Update `frontend_gateway` proxy behavior if SSR server health, fallback, or cache behavior changes.
- [x] Document required environment variables, including public browser API URL, internal server API URL, site URL, and any cookie/session settings.
- [x] Keep the upgrade maintenance page independent from Nuxt, backend API, and database.
- [x] Preserve public frontend port `20015` unless `DESIGN.md` and compose configuration are intentionally updated together.
### Phase 7 Deployment Notes
- `frontend/package.json` now uses `nuxt build` so the production build emits Nitro server output.
- `frontend/Dockerfile` now runs `node .output/server/index.mjs` with `HOST=0.0.0.0` and `PORT=20015`; the obsolete lightweight static server file was removed.
- `frontend_gateway` continues to proxy `frontend:20015` and keep the backend health-gated maintenance fallback independent from Nuxt.
- `DESIGN.md` now documents the Nuxt server output deployment model and the existing browser API, server API, site URL, origin, and proxy environment variables.
- A local smoke check of `node frontend/.output/server/index.mjs` on port `20115` returned SSR HTML for `/` and `200` for `/robots.txt`; Docker compose runtime validation is still pending.
## Phase 8: Validation
- [x] Run `pnpm --filter @pokopia/frontend typecheck`.
- [x] Run `pnpm --filter @pokopia/frontend lint`.
- [x] Run `pnpm --filter @pokopia/frontend build`.
- [ ] Do not run tests in WSL unless explicitly requested.
- [ ] Ask the user to run `docker compose up --build` for runtime validation, then fix any pasted Docker output in follow-up passes.
- [ ] Verify anonymous SSR HTML for public routes includes meaningful public content and metadata.
- [ ] Verify authenticated routes redirect correctly when unauthenticated, unverified, or missing permissions.
- [ ] Verify logged-in flows still work after hydration: login, logout, Remember me, Profile, notifications, create/edit modals, uploads, comments, reactions, and admin access.
- [ ] Verify generated HTML and Nuxt payloads do not contain forbidden internal data.
- [ ] Verify `robots.txt`, `sitemap.xml`, canonical URLs, noindex routes, and public detail metadata.
## Phase 9: Cleanup
- [ ] Remove legacy SPA-only compatibility paths once SSR behavior is stable and no longer needed.
- [x] Remove obsolete static server usage if the production frontend container runs the Nuxt server.
- [ ] Remove obsolete `VITE_*` fallback support only after deployment configuration has fully moved to `NUXT_PUBLIC_*` or documented replacement variables.
- [ ] Update `DESIGN.md` from "Nuxt SPA mode" to the final SSR deployment model.
- [ ] Update `AGENTS.md` frontend stack and workflow notes to the final SSR state.
- [ ] Delete `SSR_MIGRATION_TASKLIST.md`.
- [ ] Remove the temporary `AGENTS.md` instruction that requires reading and maintaining this task list.

View File

@@ -166,6 +166,9 @@ const app = Fastify({
logger: true,
trustProxy: process.env.TRUST_PROXY === 'true'
});
const sessionCookieName = 'pokopia_session';
const rememberedSessionDays = 30;
const sessionOnlySessionDays = 1;
function configuredCorsOrigin(): true | string | string[] {
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
@@ -183,7 +186,8 @@ function configuredCorsOrigin(): true | string | string[] {
await app.register(cors, {
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
origin: configuredCorsOrigin()
});
@@ -248,6 +252,54 @@ function getBearerToken(authorization: string | undefined): string | null {
return scheme === 'Bearer' && token ? token : null;
}
function getCookieValue(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) {
return null;
}
for (const cookiePart of cookieHeader.split(';')) {
const [rawName, ...rawValue] = cookiePart.trim().split('=');
if (rawName === name) {
try {
return decodeURIComponent(rawValue.join('='));
} catch {
return rawValue.join('=');
}
}
}
return null;
}
function getSessionToken(request: FastifyRequest): string | null {
return getCookieValue(request.headers.cookie, sessionCookieName) ?? getBearerToken(request.headers.authorization);
}
function sessionCookieSecure(): boolean {
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? '';
return origin.split(',').some((value) => value.trim().startsWith('https://'));
}
function sessionCookie(value: string, maxAgeSeconds: number): string {
return [
`${sessionCookieName}=${encodeURIComponent(value)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
`Max-Age=${maxAgeSeconds}`,
...(sessionCookieSecure() ? ['Secure'] : [])
].join('; ');
}
function setSessionCookie(reply: FastifyReply, token: string, rememberMe: boolean): void {
const sessionDays = rememberMe ? rememberedSessionDays : sessionOnlySessionDays;
reply.header('Set-Cookie', sessionCookie(token, sessionDays * 24 * 60 * 60));
}
function clearSessionCookie(reply: FastifyReply): void {
reply.header('Set-Cookie', `${sessionCookie('', 0)}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`);
}
function requestLocale(request: FastifyRequest): string {
const query = request.query as Record<string, string | string[] | undefined>;
const queryLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale;
@@ -868,7 +920,7 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
return null;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
const locale = requestLocale(request);
@@ -950,7 +1002,7 @@ async function requireAnyPermissionWithRateLimits(
}
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
if (!token) {
return null;
}
@@ -983,7 +1035,10 @@ app.post('/api/auth/login', async (request, reply) => {
return;
}
return loginUser(request.body as Record<string, unknown>, requestLocale(request));
const payload = request.body as Record<string, unknown>;
const response = await loginUser(payload, requestLocale(request));
setSessionCookie(reply, response.token, payload.rememberMe === true);
return response;
});
app.post('/api/auth/request-password-reset', async (request, reply) => {
@@ -1007,7 +1062,7 @@ app.get('/api/auth/me', async (request, reply) => {
return;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
@@ -1022,7 +1077,7 @@ app.patch('/api/auth/me', async (request, reply) => {
return;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
@@ -1042,7 +1097,7 @@ app.patch('/api/auth/me/password', async (request, reply) => {
return;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
if (!user || !token) {
@@ -1062,7 +1117,7 @@ app.get('/api/auth/referral', async (request, reply) => {
return;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
@@ -1099,11 +1154,12 @@ app.post('/api/notifications/:id/read', async (request, reply) => {
});
app.post('/api/auth/logout', async (request, reply) => {
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
if (token) {
await logoutSession(token);
}
clearSessionCookie(reply);
return reply.code(204).send();
});

View File

@@ -40,10 +40,14 @@ services:
context: .
dockerfile: frontend/Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:20016}
VITE_SITE_URL: ${VITE_SITE_URL:-https://pokopiawiki.tootaio.com}
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:
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:

View File

@@ -8,21 +8,23 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install
COPY frontend ./frontend
COPY system-wordings.ts ./system-wordings.ts
ARG VITE_API_BASE_URL=http://localhost:3001
ARG VITE_SITE_URL=https://pokopiawiki.tootaio.com
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_SITE_URL=$VITE_SITE_URL
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/dist ./dist
COPY frontend/static-server.mjs ./static-server.mjs
COPY --from=build /app/frontend/.output ./.output
USER node
EXPOSE 20015
CMD ["node", "static-server.mjs"]
CMD ["node", ".output/server/index.mjs"]

View File

@@ -1,8 +1,7 @@
<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 AppShell from './src/components/AppShell.vue';
import {
iconAction,
iconAdmin,
@@ -20,12 +19,11 @@ import {
iconPokemon,
iconRecipe,
type AppIcon
} from './icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
} from './src/icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, 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[]>([
@@ -114,18 +112,15 @@ const navItems = computed<NavItem[]>(() => {
});
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
if (getAuthToken()) {
setAuthToken(null);
}
}
}
async function logout() {
@@ -188,6 +183,6 @@ onUnmounted(() => {
@logout="logout"
@update:locale="updateLocale"
>
<RouterView :key="locale" />
<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

@@ -1,49 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#6ccf32" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="canonical" href="%POKOPIA_SITE_URL%/pokemon" />
<meta property="og:site_name" content="Pokopia Wiki" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
<meta
property="og:description"
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta property="og:url" content="%POKOPIA_SITE_URL%/pokemon" />
<meta property="og:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
<meta
name="twitter:description"
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
<script>
(function () {
const UMAMI_SCRIPT_JS = "https://umami.tootaio.com/script.js";
const UMAMI_ID = "6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb";
var script = document.createElement("script");
script.async = true;
script.src = UMAMI_SCRIPT_JS;
script.setAttribute("data-website-id", UMAMI_ID);
document.head.appendChild(script);
})();
</script>
<title>Pokopia Wiki - Pokemon Pokopia Guide</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
import { api, setAuthToken } 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();
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 {
setAuthToken(null);
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
}
});

77
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,77 @@
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_API_BASE_URL ??
process.env.NUXT_PUBLIC_API_BASE_URL ??
process.env.VITE_API_BASE_URL ??
'http://localhost:3001',
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? process.env.VITE_API_BASE_URL ?? 'http://localhost:3001',
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_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: 'description',
content:
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.'
},
{ name: 'robots', content: 'index, follow' },
{ name: 'theme-color', content: '#6ccf32' },
{ property: 'og:site_name', content: 'Pokopia Wiki' },
{ property: 'og:type', content: 'website' },
{ property: 'og:title', content: 'Pokopia Wiki - Pokemon Pokopia Guide' },
{
property: 'og:description',
content:
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.'
},
{ property: 'og:image', content: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/seo/pokopia-hero.jpg` },
{ property: 'og:locale', content: 'en_US' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: 'Pokopia Wiki - Pokemon Pokopia Guide' },
{
name: 'twitter:description',
content:
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.'
},
{ name: 'twitter:image', content: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/seo/pokopia-hero.jpg` }
],
link: [
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' },
{ rel: 'canonical', href: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/pokemon` }
],
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,16 +5,15 @@
"packageManager": "pnpm@10.33.2",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 20015",
"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": "6.0.6",
"vite": "8.0.10",
"nuxt": "4.4.4",
"vue": "3.5.33",
"vue-i18n": "11.4.0",
"vue-router": "5.0.6"
@@ -22,6 +21,7 @@
"devDependencies": {
"@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,62 @@
import { computed, ref } from 'vue';
import { onLocaleChange } from '../src/i18n';
import { applyRouteSeo, onSeoChange, 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(() => ({
title: activeSeo.value.title,
htmlAttrs: {
lang: activeSeo.value.locale
},
meta: [
{ key: 'description', name: 'description', content: activeSeo.value.description },
{ key: 'robots', name: 'robots', content: activeSeo.value.robots },
{ key: 'twitter-card', name: 'twitter:card', content: 'summary_large_image' },
{ key: 'twitter-title', name: 'twitter:title', content: activeSeo.value.title },
{ key: 'twitter-description', name: 'twitter:description', content: activeSeo.value.description },
{ key: 'twitter-image', name: 'twitter:image', content: activeSeo.value.imageUrl },
{ key: 'og-site-name', property: 'og:site_name', content: 'Pokopia Wiki' },
{ key: 'og-type', property: 'og:type', content: 'website' },
{ key: 'og-title', property: 'og:title', content: activeSeo.value.title },
{ key: 'og-description', property: 'og:description', content: activeSeo.value.description },
{ key: 'og-url', property: 'og:url', content: activeSeo.value.canonicalUrl },
{ key: 'og-image', property: 'og:image', content: activeSeo.value.imageUrl },
{ key: 'og-locale', property: 'og:locale', content: activeSeo.value.locale === 'en' ? 'en_US' : activeSeo.value.locale.replace('-', '_') }
],
link: [{ key: 'canonical', rel: 'canonical', href: activeSeo.value.canonicalUrl }],
script: [
{
key: 'pokopia-structured-data',
id: 'pokopia-structured-data',
type: 'application/ld+json',
children: JSON.stringify(activeSeo.value.structuredData)
}
]
}));
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,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,68 @@
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
const sitemapPaths = [
'/pokemon',
'/event-pokemon',
'/habitats',
'/event-habitats',
'/items',
'/event-items',
'/ancient-artifacts',
'/recipes',
'/dish',
'/checklist',
'/life'
];
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

@@ -2,7 +2,8 @@ import { createI18n } from 'vue-i18n';
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
export { defaultLocale } from '../../system-wordings';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
let browserApiBaseUrl = 'http://localhost:3001';
let serverApiBaseUrl = 'http://localhost:3001';
const localeStorageKey = 'pokopia_locale';
const localeChangeEvent = 'pokopia-locale-change';
@@ -17,15 +18,52 @@ type SystemWordingsResponse = {
export type MessageKey = keyof typeof messages.en;
export const i18n = createI18n({
export function createPokopiaI18n(initialLocale = readStoredLocale()) {
return createI18n({
legacy: false,
globalInjection: true,
locale: readStoredLocale(),
locale: initialLocale || defaultLocale,
fallbackLocale: defaultLocale,
messages
});
});
}
function readStoredLocale(): string {
type PokopiaI18n = ReturnType<typeof createPokopiaI18n>;
let activeI18n: PokopiaI18n | null = null;
export function setActiveI18n(instance: PokopiaI18n): void {
activeI18n = instance;
}
export function setSystemWordingsApiBaseUrl(value: unknown): void {
setSystemWordingsApiBaseUrls({ browser: value, server: value });
}
export function setSystemWordingsApiBaseUrls(value: { browser?: unknown; server?: unknown }): void {
const browserBaseUrl = normalizeApiBaseUrl(value.browser);
const serverBaseUrl = normalizeApiBaseUrl(value.server);
if (browserBaseUrl) {
browserApiBaseUrl = browserBaseUrl;
}
if (serverBaseUrl) {
serverApiBaseUrl = serverBaseUrl;
}
}
function normalizeApiBaseUrl(value: unknown): string | null {
if (typeof value === 'string' && value.trim() !== '') {
return value.trim().replace(/\/+$/, '');
}
return null;
}
function activeApiBaseUrl(): string {
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
}
export function readStoredLocale(): string {
if (typeof localStorage === 'undefined') {
return defaultLocale;
}
@@ -35,11 +73,11 @@ function readStoredLocale(): string {
}
function globalLocaleRef() {
return i18n.global.locale as unknown as { value: string };
return activeI18n?.global.locale as unknown as { value: string } | undefined;
}
export function getCurrentLocale(): string {
return globalLocaleRef().value || defaultLocale;
return globalLocaleRef()?.value || defaultLocale;
}
function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree {
@@ -68,6 +106,11 @@ function builtInMessagesFor(locale: string): SystemWordingTree {
}
export async function loadSystemWordings(locale = getCurrentLocale(), force = false): Promise<void> {
if (!activeI18n) {
return;
}
const targetI18n = activeI18n;
const targetLocale = locale || defaultLocale;
if (!force && loadedWordingLocales.has(targetLocale)) {
return;
@@ -81,19 +124,19 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
const loadPromise = (async () => {
try {
const response = await fetch(`${apiBaseUrl}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`);
const response = await fetch(`${activeApiBaseUrl()}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`);
if (!response.ok) {
throw new Error(`System wordings failed (${response.status})`);
}
const data = (await response.json()) as SystemWordingsResponse;
i18n.global.setLocaleMessage(
targetI18n.global.setLocaleMessage(
targetLocale,
mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never
);
loadedWordingLocales.add(targetLocale);
} catch {
i18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
targetI18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
} finally {
pendingWordingLoads.delete(targetLocale);
}
@@ -105,7 +148,10 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
export function setCurrentLocale(locale: string): void {
const nextLocale = locale || defaultLocale;
globalLocaleRef().value = nextLocale;
const localeRef = globalLocaleRef();
if (localeRef) {
localeRef.value = nextLocale;
}
if (typeof document !== 'undefined') {
document.documentElement.lang = nextLocale;
@@ -121,8 +167,10 @@ export function setCurrentLocale(locale: string): void {
}
export function onLocaleChange(callback: () => void): () => void {
if (typeof window === 'undefined') {
return () => {};
}
window.addEventListener(localeChangeEvent, callback);
return () => window.removeEventListener(localeChangeEvent, callback);
}
setCurrentLocale(getCurrentLocale());

View File

@@ -1,9 +0,0 @@
import { createApp } from 'vue';
import App from './App.vue';
import { i18n } from './i18n';
import { router } from './router';
import { setupSeo } from './seo';
import './styles/main.css';
setupSeo(router);
createApp(App).use(i18n).use(router).mount('#app');

View File

@@ -1,388 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import PokemonList from '../views/PokemonList.vue';
import PokemonDetail from '../views/PokemonDetail.vue';
import HabitatList from '../views/HabitatList.vue';
import HabitatDetail from '../views/HabitatDetail.vue';
import ItemsList from '../views/ItemsList.vue';
import ItemDetail from '../views/ItemDetail.vue';
import AncientArtifactList from '../views/AncientArtifactList.vue';
import RecipeList from '../views/RecipeList.vue';
import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue';
import LifePostDetail from '../views/LifePostDetail.vue';
import LifeView from '../views/LifeView.vue';
import DishView from '../views/DishView.vue';
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
import LegalView from '../views/LegalView.vue';
import ComingSoonView from '../views/ComingSoonView.vue';
import AdminView from '../views/AdminView.vue';
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
import LoginView from '../views/LoginView.vue';
import UserProfileView from '../views/UserProfileView.vue';
import RegisterView from '../views/RegisterView.vue';
import ResetPasswordView from '../views/ResetPasswordView.vue';
import VerifyEmailView from '../views/VerifyEmailView.vue';
import { api, getAuthToken, setAuthToken } from '../services/api';
import type { RouteSeoConfig } from '../seo';
const seo = (config: RouteSeoConfig) => config;
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView, meta: { seo: seo({ titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }) } },
{
path: '/pokemon',
name: 'pokemon-list',
component: PokemonList,
props: { eventOnly: false },
meta: { seo: seo({ titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }) }
},
{
path: '/pokemon/new',
name: 'pokemon-new',
component: PokemonList,
props: { eventOnly: false },
meta: {
requiredPermission: 'pokemon.create',
editorModal: true,
seo: seo({ titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true })
}
},
{
path: '/event-pokemon',
name: 'event-pokemon-list',
component: PokemonList,
props: { eventOnly: true },
meta: { seo: seo({ titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }) }
},
{
path: '/event-pokemon/new',
name: 'event-pokemon-new',
component: PokemonList,
props: { eventOnly: true },
meta: {
requiredPermission: 'pokemon.create',
editorModal: true,
seo: seo({ titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true })
}
},
{
path: '/pokemon/:id/edit',
name: 'pokemon-edit',
component: PokemonDetail,
meta: {
requiredPermission: 'pokemon.update',
editorModal: true,
seo: seo({
titleKey: 'pages.pokemon.editKicker',
descriptionKey: 'pages.pokemon.editSubtitle',
canonicalPath: (route) => `/pokemon/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail, meta: { seo: seo({ titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }) } },
{
path: '/habitats',
name: 'habitat-list',
component: HabitatList,
props: { eventOnly: false },
meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) }
},
{
path: '/habitats/new',
name: 'habitat-new',
component: HabitatList,
props: { eventOnly: false },
meta: {
requiredPermission: 'habitats.create',
editorModal: true,
seo: seo({ titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true })
}
},
{
path: '/event-habitats',
name: 'event-habitat-list',
component: HabitatList,
props: { eventOnly: true },
meta: { seo: seo({ titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }) }
},
{
path: '/event-habitats/new',
name: 'event-habitat-new',
component: HabitatList,
props: { eventOnly: true },
meta: {
requiredPermission: 'habitats.create',
editorModal: true,
seo: seo({ titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true })
}
},
{
path: '/habitats/:id/edit',
name: 'habitat-edit',
component: HabitatDetail,
meta: {
requiredPermission: 'habitats.update',
editorModal: true,
seo: seo({
titleKey: 'pages.habitats.detailKicker',
descriptionKey: 'pages.habitats.editSubtitle',
canonicalPath: (route) => `/habitats/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail, meta: { seo: seo({ titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }) } },
{
path: '/items',
name: 'item-list',
component: ItemsList,
props: { eventOnly: false },
meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) }
},
{
path: '/items/new',
name: 'item-new',
component: ItemsList,
props: { eventOnly: false },
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true })
}
},
{
path: '/event-items',
name: 'event-item-list',
component: ItemsList,
props: { eventOnly: true },
meta: { seo: seo({ titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }) }
},
{
path: '/event-items/new',
name: 'event-item-new',
component: ItemsList,
props: { eventOnly: true },
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true })
}
},
{
path: '/items/:id/edit',
name: 'item-edit',
component: ItemDetail,
meta: {
requiredPermission: 'items.update',
editorModal: true,
seo: seo({
titleKey: 'pages.items.editKicker',
descriptionKey: 'pages.items.editSubtitle',
canonicalPath: (route) => `/items/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/items/:id', name: 'item-detail', component: ItemDetail, meta: { seo: seo({ titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }) } },
{
path: '/ancient-artifacts',
name: 'ancient-artifact-list',
component: AncientArtifactList,
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
},
{
path: '/ancient-artifacts/new',
name: 'ancient-artifact-new',
component: AncientArtifactList,
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({
titleKey: 'pages.ancientArtifacts.newTitle',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: '/ancient-artifacts',
noindex: true
})
}
},
{
path: '/ancient-artifacts/:id/edit',
name: 'ancient-artifact-edit',
component: ItemDetail,
meta: {
requiredPermission: 'items.update',
editorModal: true,
seo: seo({
titleKey: 'pages.ancientArtifacts.editKicker',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: (route) => `/ancient-artifacts/${String(route.params.id)}`,
noindex: true
})
}
},
{
path: '/ancient-artifacts/:id',
name: 'ancient-artifact-detail',
component: ItemDetail,
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
},
{ path: '/recipes', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
{
path: '/recipes/new',
name: 'recipe-new',
component: RecipeList,
meta: {
requiredPermission: 'recipes.create',
editorModal: true,
seo: seo({ titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true })
}
},
{
path: '/recipes/:id/edit',
name: 'recipe-edit',
component: RecipeDetail,
meta: {
requiredPermission: 'recipes.update',
editorModal: true,
seo: seo({
titleKey: 'pages.recipes.editKicker',
descriptionKey: 'pages.recipes.editSubtitle',
canonicalPath: (route) => `/recipes/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail, meta: { seo: seo({ titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }) } },
{
path: '/automation',
name: 'automation',
component: ComingSoonView,
props: { page: 'automation' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }) }
},
{
path: '/dish',
name: 'dish',
component: DishView,
meta: { seo: seo({ titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }) }
},
{
path: '/events',
name: 'events',
component: ComingSoonView,
props: { page: 'events' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }) }
},
{
path: '/actions',
name: 'actions',
component: ComingSoonView,
props: { page: 'actions' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }) }
},
{
path: '/dream-island',
name: 'dream-island',
component: ComingSoonView,
props: { page: 'dreamIsland' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }) }
},
{
path: '/clothes',
name: 'clothes',
component: ComingSoonView,
props: { page: 'clothes' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }) }
},
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
{ path: '/life/:id', component: LifePostDetail, meta: { seo: seo({ titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }) } },
{
path: '/project-updates',
component: ProjectUpdatesView,
meta: {
seo: seo({
titleKey: 'pages.projectUpdates.title',
descriptionKey: 'pages.projectUpdates.subtitle',
canonicalPath: '/project-updates'
})
}
},
{
path: '/privacy-policy',
component: LegalView,
props: { page: 'privacy' },
meta: { seo: seo({ titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }) }
},
{
path: '/terms-of-service',
component: LegalView,
props: { page: 'terms' },
meta: { seo: seo({ titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }) }
},
{
path: '/disclaimers',
component: LegalView,
props: { page: 'disclaimers' },
meta: { seo: seo({ titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }) }
},
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access', seo: seo({ titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }) } },
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true, seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }) } },
{ path: '/profile/:id', component: UserProfileView, meta: { seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }) } },
{ path: '/login', component: LoginView, meta: { seo: seo({ titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }) } },
{ path: '/forgot-password', component: ForgotPasswordView, meta: { seo: seo({ titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }) } },
{ path: '/reset-password', component: ResetPasswordView, meta: { seo: seo({ titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }) } },
{ path: '/register', component: RegisterView, meta: { seo: seo({ titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }) } },
{ path: '/verify-email', component: VerifyEmailView, meta: { seo: seo({ titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }) } }
],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
return { top: 0 };
}
});
router.beforeEach(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 true;
}
if (!getAuthToken()) {
return { path: '/login', query: { redirect: to.fullPath } };
}
try {
const response = await api.me();
if (requiresVerified && !response.user.emailVerified) {
return { path: '/login', query: { redirect: to.fullPath } };
}
const permissionSet = new Set(response.user.permissions);
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
return { path: '/pokemon' };
}
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
return { path: '/pokemon' };
}
return true;
} catch {
setAuthToken(null);
return { path: '/login', query: { redirect: to.fullPath } };
}
});

View File

@@ -1,12 +1,15 @@
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router';
import { getCurrentLocale, i18n, onLocaleChange } from './i18n';
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { getCurrentLocale } from './i18n';
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
const siteName = 'Pokopia Wiki';
const defaultCanonicalPath = '/';
const defaultImagePath = '/seo/pokopia-hero.jpg';
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
let runtimeSiteUrl: string | null = null;
type TranslationValues = Record<string, string | number>;
type Translator = (key: string, values?: TranslationValues) => string;
export type RouteSeoConfig = {
title?: string;
@@ -26,12 +29,34 @@ export type SeoConfig = {
noindex?: boolean;
};
const translate = i18n.global.t as (key: string, values?: TranslationValues) => string;
export type ResolvedSeoConfig = {
title: string;
description: string;
canonicalUrl: string;
imageUrl: string;
robots: string;
locale: string;
structuredData: Record<string, unknown>;
};
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
let activeTranslator: Translator | null = null;
let currentSeo: ResolvedSeoConfig | null = null;
const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>();
export function setSeoTranslator(translator: Translator): void {
activeTranslator = translator;
}
export function setConfiguredSiteUrl(value: unknown): void {
if (typeof value === 'string' && value.trim() !== '') {
runtimeSiteUrl = normalizeSiteUrl(value);
}
}
function configuredSiteUrl(): string {
const fromEnv = import.meta.env.VITE_SITE_URL;
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') {
return normalizeSiteUrl(fromEnv);
if (runtimeSiteUrl) {
return runtimeSiteUrl;
}
if (typeof window !== 'undefined' && window.location.origin) {
@@ -68,47 +93,42 @@ function metaTitle(title?: string): string {
}
function metaDescription(description?: string): string {
return description?.trim() || translate('seo.siteDescription');
return description?.trim() || translateSeo('seo.siteDescription');
}
function localeForOpenGraph(locale: string): string {
if (locale === 'en') {
return 'en_US';
function builtInTranslate(key: string, values: TranslationValues = {}): string {
let message: SystemWordingTree[string] | undefined = messages[defaultLocale];
for (const part of key.split('.')) {
message = typeof message === 'object' && message !== null ? message[part] : undefined;
}
return locale.replace('-', '_');
if (typeof message !== 'string') {
return key;
}
return Object.entries(values).reduce((nextMessage, [name, value]) => nextMessage.replaceAll(`{${name}}`, String(value)), message);
}
function setMeta(attribute: 'name' | 'property', key: string, content: string): void {
let element = document.head.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`);
if (!element) {
element = document.createElement('meta');
element.setAttribute(attribute, key);
document.head.appendChild(element);
}
element.setAttribute('content', content);
function translateSeo(key: string, values?: TranslationValues, translator = activeTranslator): string {
return translator ? translator(key, values) : builtInTranslate(key, values);
}
function setCanonical(href: string): void {
let element = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!element) {
element = document.createElement('link');
element.setAttribute('rel', 'canonical');
document.head.appendChild(element);
}
element.setAttribute('href', href);
}
export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
const title = metaTitle(config.title);
const description = metaDescription(config.description);
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
const locale = getCurrentLocale();
function setStructuredData(title: string, description: string, canonicalUrl: string): void {
let element = document.getElementById('pokopia-structured-data') as HTMLScriptElement | null;
if (!element) {
element = document.createElement('script');
element.id = 'pokopia-structured-data';
element.type = 'application/ld+json';
document.head.appendChild(element);
}
element.textContent = JSON.stringify({
return {
title,
description,
canonicalUrl,
imageUrl,
robots,
locale,
structuredData: {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: title,
@@ -119,64 +139,43 @@ function setStructuredData(title: string, description: string, canonicalUrl: str
name: siteName,
url: absoluteUrl('/')
}
});
}
export function applySeo(config: SeoConfig = {}): void {
if (typeof document === 'undefined') {
return;
}
const title = metaTitle(config.title);
const description = metaDescription(config.description);
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
const noindex = config.noindex === true;
const robots = noindex ? 'noindex, nofollow' : 'index, follow';
const locale = getCurrentLocale();
document.title = title;
setMeta('name', 'description', description);
setMeta('name', 'robots', robots);
setMeta('name', 'twitter:card', 'summary_large_image');
setMeta('name', 'twitter:title', title);
setMeta('name', 'twitter:description', description);
setMeta('name', 'twitter:image', imageUrl);
setMeta('property', 'og:site_name', siteName);
setMeta('property', 'og:type', 'website');
setMeta('property', 'og:title', title);
setMeta('property', 'og:description', description);
setMeta('property', 'og:url', canonicalUrl);
setMeta('property', 'og:image', imageUrl);
setMeta('property', 'og:locale', localeForOpenGraph(locale));
setCanonical(canonicalUrl);
setStructuredData(title, description, canonicalUrl);
};
}
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
const canonicalPath =
typeof routeSeo?.canonicalPath === 'function'
? routeSeo.canonicalPath(route)
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
applySeo({
title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title,
description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description,
return {
title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title,
description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : routeSeo?.description,
canonicalPath,
image: routeSeo?.image,
noindex: routeSeo?.noindex
});
};
}
export function setupSeo(router: Router): void {
router.afterEach((to) => {
applyRouteSeo(to);
});
export function resolveRouteSeo(route: RouteLocationNormalizedLoaded, translator?: Translator): ResolvedSeoConfig {
return resolveSeo(routeSeoConfig(route, translator));
}
if (typeof window !== 'undefined') {
onLocaleChange(() => {
applyRouteSeo(router.currentRoute.value);
});
export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void {
seoListeners.add(callback);
callback(currentSeo ?? resolveSeo());
return () => seoListeners.delete(callback);
}
export function applySeo(config: SeoConfig = {}): void {
currentSeo = resolveSeo(config);
for (const listener of seoListeners) {
listener(currentSeo);
}
}
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
applySeo(routeSeoConfig(route));
}

View File

@@ -1,6 +1,7 @@
import { getCurrentLocale } from '../i18n';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
let browserApiBaseUrl = 'http://localhost:3001';
let serverApiBaseUrl = 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change';
@@ -15,6 +16,38 @@ export interface Language {
sortOrder: number;
}
export function setApiBaseUrl(value: unknown): void {
setApiBaseUrls({ browser: value, server: value });
}
export function setApiBaseUrls(value: { browser?: unknown; server?: unknown }): void {
const browserBaseUrl = normalizeApiBaseUrl(value.browser);
const serverBaseUrl = normalizeApiBaseUrl(value.server);
if (browserBaseUrl) {
browserApiBaseUrl = browserBaseUrl;
}
if (serverBaseUrl) {
serverApiBaseUrl = serverBaseUrl;
}
}
function normalizeApiBaseUrl(value: unknown): string | null {
if (typeof value === 'string' && value.trim() !== '') {
return value.trim().replace(/\/+$/, '');
}
return null;
}
function activeApiBaseUrl(): string {
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
}
function apiUrl(path: string): string {
return `${activeApiBaseUrl()}${path}`;
}
export type SystemWordingSurface = 'frontend' | 'backend' | 'email';
export interface SystemWording {
@@ -1057,6 +1090,10 @@ export function setAuthToken(token: string | null, options: { persistent?: boole
}
export function onAuthTokenChange(callback: () => void): () => void {
if (typeof window === 'undefined') {
return () => {};
}
window.addEventListener(authChangeEvent, callback);
return () => window.removeEventListener(authChangeEvent, callback);
}
@@ -1076,7 +1113,7 @@ function requestHeaders(): HeadersInit {
}
export function notificationWebSocketUrl(ticket: string): string {
const base = new URL(apiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
const base = new URL(browserApiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
base.pathname = '/api/notifications/ws';
base.search = '';
@@ -1098,7 +1135,8 @@ async function getErrorMessage(response: Response): Promise<string> {
}
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
headers: requestHeaders(),
signal
});
@@ -1111,7 +1149,8 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
}
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method,
headers: {
'Content-Type': 'application/json',
@@ -1128,7 +1167,8 @@ async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body:
}
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'POST',
headers: requestHeaders(),
body
@@ -1142,7 +1182,8 @@ async function sendFormData<T>(path: string, body: FormData): Promise<T> {
}
async function postEmpty(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'POST',
headers: requestHeaders()
});
@@ -1153,7 +1194,8 @@ async function postEmpty(path: string): Promise<void> {
}
async function deleteJson(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'DELETE',
headers: requestHeaders()
});
@@ -1164,7 +1206,8 @@ async function deleteJson(path: string): Promise<void> {
}
async function deleteAndGetJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'DELETE',
headers: requestHeaders()
});

View File

@@ -1,82 +0,0 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dist');
const indexPath = path.join(root, 'index.html');
const host = process.env.HOST ?? '0.0.0.0';
const port = Number.parseInt(process.env.PORT ?? '20015', 10);
const contentTypes = new Map([
['.css', 'text/css; charset=utf-8'],
['.gif', 'image/gif'],
['.html', 'text/html; charset=utf-8'],
['.ico', 'image/x-icon'],
['.jpg', 'image/jpeg'],
['.js', 'text/javascript; charset=utf-8'],
['.json', 'application/json; charset=utf-8'],
['.png', 'image/png'],
['.svg', 'image/svg+xml'],
['.txt', 'text/plain; charset=utf-8'],
['.wasm', 'application/wasm'],
['.webmanifest', 'application/manifest+json; charset=utf-8'],
['.webp', 'image/webp'],
['.woff', 'font/woff'],
['.woff2', 'font/woff2'],
['.xml', 'application/xml; charset=utf-8']
]);
function isInsideRoot(filePath) {
const relativePath = path.relative(root, filePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}
function resolvePath(url) {
try {
const pathname = new URL(url ?? '/', 'http://localhost').pathname;
const filePath = path.resolve(root, `.${decodeURIComponent(pathname)}`);
return isInsideRoot(filePath) ? filePath : indexPath;
} catch {
return indexPath;
}
}
async function findStaticFile(url) {
const filePath = resolvePath(url);
try {
const fileStat = await stat(filePath);
if (fileStat.isFile()) {
return { filePath, fileStat };
}
} catch {
return { filePath: indexPath, fileStat: await stat(indexPath) };
}
return { filePath: indexPath, fileStat: await stat(indexPath) };
}
createServer(async (request, response) => {
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405);
response.end();
return;
}
const { filePath, fileStat } = await findStaticFile(request.url);
const contentType = contentTypes.get(path.extname(filePath)) ?? 'application/octet-stream';
response.writeHead(200, {
'Cache-Control': filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable',
'Content-Length': fileStat.size,
'Content-Type': contentType
});
if (request.method === 'HEAD') {
response.end();
return;
}
createReadStream(filePath).pipe(response);
}).listen(port, host);

View File

@@ -1,8 +1,10 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"strict": true,
"types": ["vite/client", "vitest/globals"]
"noUncheckedIndexedAccess": false,
"noImplicitOverride": false,
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "../system-wordings.ts"]
"include": [".nuxt/**/*.d.ts", "**/*.d.ts", "**/*.ts", "**/*.vue", "../system-wordings.ts"]
}

14
frontend/types/page-meta.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import type { RouteSeoConfig } from '../src/seo';
declare module '#app' {
interface PageMeta {
editorModal?: boolean;
requiredAnyPermission?: string[];
requiredPermission?: string;
requiresAuth?: boolean;
requiresVerified?: boolean;
seo?: RouteSeoConfig;
}
}
export {};

View File

@@ -1,120 +0,0 @@
import { defineConfig, loadEnv, type PluginOption } from 'vite';
import vue from '@vitejs/plugin-vue';
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
const frontendPort = 20015;
const sitemapPaths = [
'/pokemon',
'/event-pokemon',
'/habitats',
'/event-habitats',
'/items',
'/event-items',
'/ancient-artifacts',
'/recipes',
'/dish',
'/checklist',
'/life'
];
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'
];
function normalizeSiteUrl(value: string | undefined): string {
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
}
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`;
}
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>
`;
}
function seoFilesPlugin(siteUrl: string): PluginOption {
return {
name: 'pokopia-seo-files',
transformIndexHtml(html) {
return html.replaceAll('%POKOPIA_SITE_URL%', siteUrl);
},
configureServer(server) {
server.middlewares.use((request, response, next) => {
if (request.url === '/robots.txt') {
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.end(robotsTxt(siteUrl));
return;
}
if (request.url === '/sitemap.xml') {
response.setHeader('Content-Type', 'application/xml; charset=utf-8');
response.end(sitemapXml(siteUrl));
return;
}
next();
});
},
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'robots.txt',
source: robotsTxt(siteUrl)
});
this.emitFile({
type: 'asset',
fileName: 'sitemap.xml',
source: sitemapXml(siteUrl)
});
}
};
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const siteUrl = normalizeSiteUrl(process.env.VITE_SITE_URL ?? env.VITE_SITE_URL);
return {
plugins: [vue(), seoFilesPlugin(siteUrl)],
server: {
port: frontendPort
}
};
});

5993
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff