Compare commits
2 Commits
35ee164794
...
f7986ca520
| Author | SHA1 | Date | |
|---|---|---|---|
| f7986ca520 | |||
| 425f2f4d5f |
@@ -37,6 +37,7 @@ Keep this file aligned with implementation progress while the SSR migration is i
|
||||
- [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.
|
||||
- [x] Pokemon and Event Pokemon list SSR reads degrade to null initial data and existing skeleton/empty UI without rendering raw backend errors.
|
||||
|
||||
## Phase 3: Authentication And Session Model
|
||||
|
||||
@@ -47,7 +48,9 @@ Keep this file aligned with implementation progress while the SSR migration is i
|
||||
- [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.
|
||||
- [x] Server-side route middleware forwards the incoming HTTP-only session cookie to `api.me()` for authenticated, verified, and permissioned route checks.
|
||||
- [ ] Ensure public SSR pages never render private current-user data into HTML meant for anonymous users.
|
||||
- [x] Pokemon and Event Pokemon list SSR reads do not call `api.me()` or forward cookies; create actions remain client-hydrated after mount.
|
||||
- [x] Add a clear logout flow that clears both server cookies and any legacy client storage during the transition.
|
||||
|
||||
### Phase 3 Auth Notes
|
||||
@@ -56,6 +59,7 @@ Keep this file aligned with implementation progress while the SSR migration is i
|
||||
- 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.
|
||||
- Server-side auth middleware forwards the incoming SSR request cookie only for `api.me()` checks, allowing HTTP-only session cookies to participate in SSR route redirects without adding private auth headers to public page data requests.
|
||||
|
||||
## Phase 4: Nuxt SSR Enablement
|
||||
|
||||
@@ -88,13 +92,24 @@ Keep this file aligned with implementation progress while the SSR migration is i
|
||||
## Phase 5: Server-Side Data And SEO
|
||||
|
||||
- [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages.
|
||||
- [x] Pokemon and Event Pokemon list routes SSR-load shared options and the first public list page.
|
||||
- [ ] 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.
|
||||
- [x] Route-level SEO output now owns dynamic title, description, canonical, robots, Open Graph, Twitter card, and valid inline JSON-LD without duplicate static Nuxt head metadata.
|
||||
- [ ] 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.
|
||||
- [x] SEO resolver defaults authenticated, verified, and permissioned routes to `noindex`, while existing route metadata continues to mark auth, edit/create modal, and in-development pages as `noindex`.
|
||||
- [ ] Keep `robots.txt` and `sitemap.xml` generated from the same stable public route set documented in `DESIGN.md`.
|
||||
- [x] Sitemap includes Home, public index sections, Project Updates, and legal pages; robots keeps auth, admin, edit/create, and in-development routes disallowed.
|
||||
- [ ] 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 5 Public Data Notes
|
||||
|
||||
- Pokemon and Event Pokemon list routes now SSR-load the shared options payload and first public list page through `useAsyncData`; filter changes, infinite loading, and route-backed create modals continue to use the existing client behavior.
|
||||
- Pokemon list SSR API failures are contained to null initial data so rendered HTML falls back to the existing skeleton/empty behavior without exposing backend stack traces, raw errors, or internal fields.
|
||||
- Public Pokemon list SSR data does not request `api.me()` or forward cookies; create actions remain client-hydrated from the current user after mount.
|
||||
- The static fallback SEO tags in Nuxt config were reduced to non-route-specific defaults so route-level SSR SEO is the single source for canonical, robots, social metadata, and JSON-LD.
|
||||
|
||||
## 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.
|
||||
@@ -134,6 +149,11 @@ Keep this file aligned with implementation progress while the SSR migration is i
|
||||
- [ ] 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 8 Validation Notes
|
||||
|
||||
- 2026-05-06: After SSR auth cookie forwarding and Pokemon/Event Pokemon first-page SSR data, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. The current `lint` script runs `nuxt typecheck`.
|
||||
- 2026-05-06: After SEO foundation updates, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. Local built-server smoke on port `20116` verified `/pokemon` route-level canonical/meta/JSON-LD, `sitemap.xml`, and `robots.txt`.
|
||||
|
||||
## Phase 9: Cleanup
|
||||
|
||||
- [ ] Remove legacy SPA-only compatibility paths once SSR behavior is stable and no longer needed.
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
const response = await api.me(import.meta.server ? { headers: useRequestHeaders(['cookie']) } : undefined);
|
||||
if (requiresVerified && !response.user.emailVerified) {
|
||||
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
|
||||
@@ -30,35 +30,10 @@ export default defineNuxtConfig({
|
||||
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` }
|
||||
{ name: 'theme-color', content: '#6ccf32' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' },
|
||||
{ rel: 'canonical', href: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/pokemon` }
|
||||
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' }
|
||||
],
|
||||
script: [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineNuxtPlugin(() => {
|
||||
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));
|
||||
const structuredDataJson = computed(() => JSON.stringify(activeSeo.value.structuredData).replace(/</g, '\\u003C'));
|
||||
|
||||
useHead(() => ({
|
||||
title: activeSeo.value.title,
|
||||
@@ -35,7 +36,7 @@ export default defineNuxtPlugin(() => {
|
||||
key: 'pokopia-structured-data',
|
||||
id: 'pokopia-structured-data',
|
||||
type: 'application/ld+json',
|
||||
children: JSON.stringify(activeSeo.value.structuredData)
|
||||
innerHTML: structuredDataJson.value
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
|
||||
const sitemapPaths = [
|
||||
'/',
|
||||
'/pokemon',
|
||||
'/event-pokemon',
|
||||
'/habitats',
|
||||
@@ -11,7 +12,11 @@ const sitemapPaths = [
|
||||
'/recipes',
|
||||
'/dish',
|
||||
'/checklist',
|
||||
'/life'
|
||||
'/life',
|
||||
'/project-updates',
|
||||
'/privacy-policy',
|
||||
'/terms-of-service',
|
||||
'/disclaimers'
|
||||
];
|
||||
|
||||
const robotsDisallowPaths = [
|
||||
|
||||
@@ -149,13 +149,20 @@ export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?
|
||||
typeof routeSeo?.canonicalPath === 'function'
|
||||
? routeSeo.canonicalPath(route)
|
||||
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
|
||||
const requiresPrivateAccess = route.matched.some(
|
||||
(record) =>
|
||||
record.meta.requiresAuth === true ||
|
||||
record.meta.requiresVerified === true ||
|
||||
typeof record.meta.requiredPermission === 'string' ||
|
||||
(Array.isArray(record.meta.requiredAnyPermission) && record.meta.requiredAnyPermission.length > 0)
|
||||
);
|
||||
|
||||
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
|
||||
noindex: routeSeo?.noindex === true || requiresPrivateAccess
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ let serverApiBaseUrl = 'http://localhost:3001';
|
||||
const authTokenKey = 'pokopia_auth_token';
|
||||
const authChangeEvent = 'pokopia-auth-change';
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
|
||||
export type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
|
||||
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
|
||||
|
||||
@@ -1104,12 +1109,15 @@ export function notifyAuthChange(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function requestHeaders(): HeadersInit {
|
||||
function requestHeaders(extraHeaders?: HeadersInit): Headers {
|
||||
const headers = new Headers(extraHeaders);
|
||||
const token = getAuthToken();
|
||||
return {
|
||||
'X-Locale': getCurrentLocale(),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
};
|
||||
headers.set('X-Locale', headers.get('X-Locale') ?? getCurrentLocale());
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function notificationWebSocketUrl(ticket: string): string {
|
||||
@@ -1134,11 +1142,24 @@ async function getErrorMessage(response: Response): Promise<string> {
|
||||
return `Request failed (${response.status})`;
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
function normalizeRequestOptions(options?: AbortSignal | ApiRequestOptions): ApiRequestOptions {
|
||||
if (!options) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if ('aborted' in options && 'addEventListener' in options) {
|
||||
return { signal: options };
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string, options?: AbortSignal | ApiRequestOptions): Promise<T> {
|
||||
const requestOptions = normalizeRequestOptions(options);
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'include',
|
||||
headers: requestHeaders(),
|
||||
signal
|
||||
headers: requestHeaders(requestOptions.headers),
|
||||
signal: requestOptions.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1149,13 +1170,13 @@ 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 headers = requestHeaders();
|
||||
headers.set('Content-Type', 'application/json');
|
||||
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'include',
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...requestHeaders()
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
@@ -1261,7 +1282,7 @@ export const api = {
|
||||
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
|
||||
resetPassword: (payload: { token: string; password: string }) =>
|
||||
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||
me: (options?: ApiRequestOptions) => getJson<{ user: AuthUser }>('/api/auth/me', options),
|
||||
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||
changePassword: (payload: ChangePasswordPayload) =>
|
||||
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),
|
||||
|
||||
@@ -10,22 +10,15 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import { iconAdd } from '../icons';
|
||||
import { api, getAuthToken, type AuthUser, type Options, type Pokemon } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type ListPage, type Options, type Pokemon } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
eventOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const options = ref<Options | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const pokemon = ref<Pokemon[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMorePokemon = ref(false);
|
||||
const { t, locale } = useI18n();
|
||||
const search = ref('');
|
||||
const environmentId = ref('');
|
||||
const skillIds = ref<string[]>([]);
|
||||
@@ -37,6 +30,11 @@ const skeletonCardCount = 6;
|
||||
const listPageSize = 24;
|
||||
let loadRequestId = 0;
|
||||
|
||||
type PokemonListInitialData = {
|
||||
options: Options | null;
|
||||
page: ListPage<Pokemon> | null;
|
||||
};
|
||||
|
||||
const query = computed(() => ({
|
||||
search: search.value,
|
||||
isEventItem: props.eventOnly ? 'true' : 'false',
|
||||
@@ -46,6 +44,36 @@ const query = computed(() => ({
|
||||
favoriteThingIds: favoriteThingIds.value.join(','),
|
||||
favoriteThingMode: favoriteThingMode.value
|
||||
}));
|
||||
|
||||
const { data: initialData } = await useAsyncData<PokemonListInitialData>(
|
||||
`${props.eventOnly ? 'event-pokemon-list-initial' : 'pokemon-list-initial'}:${locale.value}`,
|
||||
async () => {
|
||||
const [optionsResult, pokemonResult] = await Promise.allSettled([
|
||||
api.options(),
|
||||
api.pokemonPage({
|
||||
...query.value,
|
||||
cursor: null,
|
||||
limit: listPageSize
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
|
||||
page: pokemonResult.status === 'fulfilled' ? pokemonResult.value : null
|
||||
};
|
||||
},
|
||||
{ default: () => ({ options: null, page: null }) }
|
||||
);
|
||||
|
||||
const initialPage = initialData.value?.page ?? null;
|
||||
const options = ref<Options | null>(initialData.value?.options ?? null);
|
||||
const pokemon = ref<Pokemon[]>(initialPage?.items ?? []);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const initialPageLoaded = ref(initialPage !== null);
|
||||
const loading = ref(!initialPageLoaded.value);
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(initialPage?.nextCursor ?? null);
|
||||
const hasMorePokemon = ref(initialPage?.hasMore ?? false);
|
||||
const showEditor = computed(() => route.name === 'pokemon-new' || route.name === 'event-pokemon-new');
|
||||
const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true);
|
||||
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventPokemon.title' : 'pages.pokemon.title'));
|
||||
@@ -88,6 +116,14 @@ async function loadPokemon(reset = true) {
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMorePokemon.value = page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
} catch {
|
||||
if (requestId === loadRequestId && reset) {
|
||||
pokemon.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMorePokemon.value = false;
|
||||
initialPageLoaded.value = true;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
@@ -112,8 +148,16 @@ onMounted(async () => {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
options.value = await api.options();
|
||||
await loadPokemon();
|
||||
if (!options.value) {
|
||||
try {
|
||||
options.value = await api.options();
|
||||
} catch {
|
||||
options.value = null;
|
||||
}
|
||||
}
|
||||
if (!initialPageLoaded.value) {
|
||||
await loadPokemon();
|
||||
}
|
||||
});
|
||||
|
||||
watch(query, () => {
|
||||
|
||||
Reference in New Issue
Block a user