diff --git a/SSR_MIGRATION_TASKLIST.md b/SSR_MIGRATION_TASKLIST.md index 7944c7d..3b4eda4 100644 --- a/SSR_MIGRATION_TASKLIST.md +++ b/SSR_MIGRATION_TASKLIST.md @@ -93,6 +93,10 @@ Keep this file aligned with implementation progress while the SSR migration is i - [ ] 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. + - [x] Habitat and Event Habitat list routes SSR-load the first public list page. + - [x] Items, Event Items, Ancient Artifacts, and Recipes list routes SSR-load shared options and the first public list page. + - [x] Daily CheckList and Dish routes SSR-load their public business data. + - [x] Home and Project Updates SSR-load the public project update preview/feed. - [ ] 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. @@ -108,6 +112,7 @@ Keep this file aligned with implementation progress while the SSR migration is i - 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. +- Habitat/Event Habitat, Items/Event Items, Ancient Artifacts, Recipes, Daily CheckList, Dish, Home, and Project Updates now use contained `useAsyncData` public reads for SSR initial content. Client-side auth reads, editor-only options, filters, infinite loading, ordering, and route-backed modals remain hydrated 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 @@ -153,6 +158,7 @@ Keep this file aligned with implementation progress while the SSR migration is i - 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`. +- 2026-05-06: After the first public list SSR data expansion, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. The build completed with existing Nuxt/Nitro warnings only. ## Phase 9: Cleanup diff --git a/frontend/src/views/AncientArtifactList.vue b/frontend/src/views/AncientArtifactList.vue index 99b3394..3984629 100644 --- a/frontend/src/views/AncientArtifactList.vue +++ b/frontend/src/views/AncientArtifactList.vue @@ -11,11 +11,11 @@ import Skeleton from '../components/Skeleton.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; import TagsSelect from '../components/TagsSelect.vue'; import { iconAdd, iconArtifact } from '../icons'; -import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api'; +import { api, getAuthToken, type AncientArtifact, type AuthUser, type ListPage, type Options } from '../services/api'; import ItemEdit from './ItemEdit.vue'; const route = useRoute(); -const { t } = useI18n(); +const { t, locale } = useI18n(); const options = ref(null); const artifacts = ref([]); const currentUser = ref(null); @@ -42,6 +42,40 @@ const artifactQuery = computed(() => ({ categoryId: categoryId.value, tagIds: tagIds.value.join(',') })); + +type AncientArtifactListInitialData = { + options: Options | null; + page: ListPage | null; +}; + +const { data: initialData } = await useAsyncData( + `ancient-artifact-list-initial:${locale.value}`, + async () => { + const [optionsResult, artifactsResult] = await Promise.allSettled([ + api.options(), + api.ancientArtifactsPage({ + ...artifactQuery.value, + cursor: null, + limit: listPageSize + }) + ]); + + return { + options: optionsResult.status === 'fulfilled' ? optionsResult.value : null, + page: artifactsResult.status === 'fulfilled' ? artifactsResult.value : null + }; + }, + { default: () => ({ options: null, page: null }) } +); + +const initialPage = initialData.value?.page ?? null; +options.value = initialData.value?.options ?? null; +artifacts.value = initialPage?.items ?? []; +const initialPageLoaded = ref(initialPage !== null); +loading.value = !initialPageLoaded.value; +nextCursor.value = initialPage?.nextCursor ?? null; +hasMoreArtifacts.value = initialPage?.hasMore ?? false; + const showEditor = computed(() => route.name === 'ancient-artifact-new'); const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true); @@ -83,6 +117,14 @@ async function loadArtifacts(reset = true) { } nextCursor.value = page.nextCursor; hasMoreArtifacts.value = page.hasMore; + initialPageLoaded.value = true; + } catch { + if (requestId === loadRequestId && reset) { + artifacts.value = []; + nextCursor.value = null; + hasMoreArtifacts.value = false; + initialPageLoaded.value = true; + } } finally { if (requestId === loadRequestId) { loading.value = false; @@ -103,8 +145,16 @@ onMounted(async () => { currentUser.value = null; } } - options.value = await api.options(); - await loadArtifacts(); + if (!options.value) { + try { + options.value = await api.options(); + } catch { + options.value = null; + } + } + if (!initialPageLoaded.value) { + await loadArtifacts(); + } }); watch(artifactQuery, () => { diff --git a/frontend/src/views/DailyChecklistView.vue b/frontend/src/views/DailyChecklistView.vue index 898e657..1820586 100644 --- a/frontend/src/views/DailyChecklistView.vue +++ b/frontend/src/views/DailyChecklistView.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'; import LoadMoreSentinel from '../components/LoadMoreSentinel.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; -import { api, type DailyChecklistItem } from '../services/api'; +import { api, type DailyChecklistItem, type ListPage } from '../services/api'; type ChecklistState = { date: string; @@ -12,7 +12,7 @@ type ChecklistState = { }; const checklistStateKey = 'pokopia_daily_checklist_state'; -const { t } = useI18n(); +const { t, locale } = useI18n(); const stateRefreshIntervalMs = 60_000; const checklistItems = ref([]); const checkedTaskIds = ref>(new Set()); @@ -25,6 +25,28 @@ const listPageSize = 20; let stateRefreshTimer: number | null = null; let loadRequestId = 0; +const { data: initialData } = await useAsyncData | null>( + `daily-checklist-initial:${locale.value}`, + async () => { + try { + return await api.dailyChecklistPage({ + cursor: null, + limit: listPageSize + }); + } catch { + return null; + } + }, + { default: () => null } +); + +const initialPage = initialData.value; +checklistItems.value = initialPage?.items ?? []; +const initialPageLoaded = ref(initialPage !== null); +loading.value = !initialPageLoaded.value; +nextCursor.value = initialPage?.nextCursor ?? null; +hasMoreItems.value = initialPage?.hasMore ?? false; + function todayKey() { const today = new Date(); const year = today.getFullYear(); @@ -124,9 +146,17 @@ async function loadDailyChecklist(reset = true) { } nextCursor.value = page.nextCursor; hasMoreItems.value = page.hasMore; + initialPageLoaded.value = true; if (!page.hasMore) { syncChecklistState(); } + } catch { + if (requestId === loadRequestId && reset) { + checklistItems.value = []; + nextCursor.value = null; + hasMoreItems.value = false; + initialPageLoaded.value = true; + } } finally { if (requestId === loadRequestId) { loading.value = false; @@ -141,8 +171,13 @@ function loadMoreDailyChecklist() { onMounted(() => { loadChecklistState(); + if (initialPageLoaded.value && !hasMoreItems.value) { + syncChecklistState(); + } stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs); - void loadDailyChecklist(); + if (!initialPageLoaded.value) { + void loadDailyChecklist(); + } }); onUnmounted(() => { diff --git a/frontend/src/views/DishView.vue b/frontend/src/views/DishView.vue index 3ccae2f..1be9469 100644 --- a/frontend/src/views/DishView.vue +++ b/frontend/src/views/DishView.vue @@ -25,7 +25,7 @@ import { type TranslationMap } from '../services/api'; -const { t } = useI18n(); +const { t, locale } = useI18n(); const categories = ref([]); const activeCategoryId = ref(''); const loading = ref(true); @@ -96,6 +96,24 @@ const dishFormValid = computed( dishForm.value.mosslaxEffect.trim() !== '' ); +const { data: initialData } = await useAsyncData( + `dish-initial:${locale.value}`, + async () => { + try { + return await api.dish(); + } catch { + return null; + } + }, + { default: () => null } +); + +const initialCategories = initialData.value; +categories.value = initialCategories ?? []; +activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : ''; +const initialCategoriesLoaded = ref(initialCategories !== null); +loading.value = !initialCategoriesLoaded.value; + function itemImage(item: ItemLink) { return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : null; } @@ -221,6 +239,7 @@ async function loadDish(showSkeleton = false) { } categories.value = await api.dish(); activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : ''; + initialCategoriesLoaded.value = true; loading.value = false; } @@ -289,7 +308,7 @@ async function loadPage() { currentUser.value = null; } } - await Promise.all([loadDish(), loadEditorOptions()]); + await Promise.all([initialCategoriesLoaded.value ? Promise.resolve() : loadDish(), loadEditorOptions()]); } watch(categories, (nextCategories) => { diff --git a/frontend/src/views/HabitatList.vue b/frontend/src/views/HabitatList.vue index 2d2d189..4fed4d7 100644 --- a/frontend/src/views/HabitatList.vue +++ b/frontend/src/views/HabitatList.vue @@ -8,7 +8,7 @@ import LoadMoreSentinel from '../components/LoadMoreSentinel.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import { iconAdd, iconHabitat } from '../icons'; -import { api, getAuthToken, type AuthUser, type Habitat } from '../services/api'; +import { api, getAuthToken, type AuthUser, type Habitat, type ListPage } from '../services/api'; import HabitatEdit from './HabitatEdit.vue'; const props = defineProps<{ @@ -18,8 +18,7 @@ const props = defineProps<{ const habitats = ref([]); const currentUser = ref(null); const route = useRoute(); -const { t } = useI18n(); -const loading = ref(true); +const { t, locale } = useI18n(); const loadingMore = ref(false); const nextCursor = ref(null); const hasMoreHabitats = ref(false); @@ -29,6 +28,30 @@ let loadRequestId = 0; const query = computed(() => ({ isEventItem: props.eventOnly ? 'true' : 'false' })); + +const { data: initialData } = await useAsyncData | null>( + `${props.eventOnly ? 'event-habitat-list-initial' : 'habitat-list-initial'}:${locale.value}`, + async () => { + try { + return await api.habitatsPage({ + ...query.value, + cursor: null, + limit: listPageSize + }); + } catch { + return null; + } + }, + { default: () => null } +); + +const initialPage = initialData.value; +habitats.value = initialPage?.items ?? []; +const initialPageLoaded = ref(initialPage !== null); +const loading = ref(!initialPageLoaded.value); +nextCursor.value = initialPage?.nextCursor ?? null; +hasMoreHabitats.value = initialPage?.hasMore ?? false; + const showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new'); const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true); const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.title' : 'pages.habitats.title')); @@ -75,6 +98,14 @@ async function loadHabitats(reset = true) { } nextCursor.value = page.nextCursor; hasMoreHabitats.value = page.hasMore; + initialPageLoaded.value = true; + } catch { + if (requestId === loadRequestId && reset) { + habitats.value = []; + nextCursor.value = null; + hasMoreHabitats.value = false; + initialPageLoaded.value = true; + } } finally { if (requestId === loadRequestId) { loading.value = false; @@ -95,7 +126,9 @@ onMounted(async () => { currentUser.value = null; } } - await loadHabitats(); + if (!initialPageLoaded.value) { + await loadHabitats(); + } }); watch(query, () => { diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 9eaeff3..cd7ac2b 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -63,8 +63,27 @@ const showProjectUpdates = computed( const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0); const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null)); +const { data: initialProjectUpdates } = await useAsyncData( + `home-project-updates:${locale.value}`, + async () => { + try { + return await api.projectUpdates({ limit: projectCommitPageSize }); + } catch { + return null; + } + }, + { default: () => null } +); + +projectUpdates.value = initialProjectUpdates.value; +projectCommits.value = initialProjectUpdates.value?.commits.items ?? []; +const initialProjectUpdatesLoaded = ref(initialProjectUpdates.value !== null); +projectUpdatesLoading.value = !initialProjectUpdatesLoaded.value; + onMounted(() => { - void loadProjectUpdates(); + if (!initialProjectUpdatesLoaded.value) { + void loadProjectUpdates(); + } }); function sectionTitleKey(key: string) { @@ -81,9 +100,11 @@ async function loadProjectUpdates(): Promise { const updates = await api.projectUpdates({ limit: projectCommitPageSize }); projectUpdates.value = updates; projectCommits.value = updates.commits.items; + initialProjectUpdatesLoaded.value = true; } catch { projectUpdates.value = null; projectCommits.value = []; + initialProjectUpdatesLoaded.value = true; } finally { projectUpdatesLoading.value = false; } diff --git a/frontend/src/views/ItemsList.vue b/frontend/src/views/ItemsList.vue index f768eeb..85783da 100644 --- a/frontend/src/views/ItemsList.vue +++ b/frontend/src/views/ItemsList.vue @@ -11,7 +11,7 @@ import Skeleton from '../components/Skeleton.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; import TagsSelect from '../components/TagsSelect.vue'; import { iconAdd, iconChevronDown, iconChevronUp, iconItem } from '../icons'; -import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api'; +import { api, getAuthToken, type AuthUser, type Item, type ListPage, type Options } from '../services/api'; import ItemEdit from './ItemEdit.vue'; const props = defineProps<{ @@ -21,7 +21,7 @@ const props = defineProps<{ const options = ref(null); const route = useRoute(); const router = useRouter(); -const { t } = useI18n(); +const { t, locale } = useI18n(); const items = ref([]); const currentUser = ref(null); const loading = ref(true); @@ -104,6 +104,40 @@ const itemQuery = computed(() => ({ tagIds: tagIds.value.join(','), isEventItem: props.eventOnly })); + +type ItemListInitialData = { + options: Options | null; + page: ListPage | null; +}; + +const { data: initialData } = await useAsyncData( + `${props.eventOnly ? 'event-item-list-initial' : 'item-list-initial'}:${locale.value}`, + async () => { + const [optionsResult, itemsResult] = await Promise.allSettled([ + api.options(), + api.itemsPage({ + ...itemQuery.value, + cursor: null, + limit: listPageSize + }) + ]); + + return { + options: optionsResult.status === 'fulfilled' ? optionsResult.value : null, + page: itemsResult.status === 'fulfilled' ? itemsResult.value : null + }; + }, + { default: () => ({ options: null, page: null }) } +); + +const initialPage = initialData.value?.page ?? null; +options.value = initialData.value?.options ?? null; +items.value = initialPage?.items ?? []; +const initialPageLoaded = ref(initialPage !== null); +loading.value = !initialPageLoaded.value; +nextCursor.value = initialPage?.nextCursor ?? null; +hasMoreItems.value = initialPage?.hasMore ?? false; + const showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new'); const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true); const hasItemCreateDefaults = computed( @@ -458,6 +492,14 @@ async function loadItems(reset = true) { } nextCursor.value = page.nextCursor; hasMoreItems.value = page.hasMore; + initialPageLoaded.value = true; + } catch { + if (requestId === loadRequestId && reset) { + items.value = []; + nextCursor.value = null; + hasMoreItems.value = false; + initialPageLoaded.value = true; + } } finally { if (requestId === loadRequestId) { loading.value = false; @@ -480,9 +522,17 @@ onMounted(async () => { currentUser.value = null; } } - options.value = await api.options(); + if (!options.value) { + try { + options.value = await api.options(); + } catch { + options.value = null; + } + } sanitizeItemCreateDefaults(); - await loadItems(); + if (!initialPageLoaded.value) { + await loadItems(); + } }); onBeforeUnmount(() => { diff --git a/frontend/src/views/ProjectUpdatesView.vue b/frontend/src/views/ProjectUpdatesView.vue index d580e65..58549b3 100644 --- a/frontend/src/views/ProjectUpdatesView.vue +++ b/frontend/src/views/ProjectUpdatesView.vue @@ -27,11 +27,33 @@ const loadMoreSentinel = ref(null); const expandedCommitShas = ref>(new Set()); let projectUpdatesObserver: IntersectionObserver | null = null; +const { data: initialData } = await useAsyncData( + `project-updates-initial:${locale.value}`, + async () => { + try { + return await api.projectUpdates({ limit: projectCommitPageSize }); + } catch { + return null; + } + }, + { default: () => null } +); + +const initialUpdates = initialData.value; +projectUpdates.value = initialUpdates; +projectCommits.value = initialUpdates?.commits.items ?? []; +projectCommitCursor.value = initialUpdates?.commits.nextCursor ?? null; +projectHasMoreCommits.value = initialUpdates?.commits.hasMore ?? false; +const initialUpdatesLoaded = ref(initialUpdates !== null); +loading.value = !initialUpdatesLoaded.value; + const releases = computed(() => projectUpdates.value?.releases ?? []); const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null)); onMounted(() => { - void loadProjectUpdates(); + if (!initialUpdatesLoaded.value) { + void loadProjectUpdates(); + } }); onBeforeUnmount(() => { @@ -53,9 +75,11 @@ async function loadProjectUpdates(): Promise { projectCommits.value = updates.commits.items; projectCommitCursor.value = updates.commits.nextCursor; projectHasMoreCommits.value = updates.commits.hasMore; + initialUpdatesLoaded.value = true; } catch { projectUpdates.value = null; projectCommits.value = []; + initialUpdatesLoaded.value = true; loadError.value = true; } finally { loading.value = false; diff --git a/frontend/src/views/RecipeList.vue b/frontend/src/views/RecipeList.vue index a1c99b7..2acf4d4 100644 --- a/frontend/src/views/RecipeList.vue +++ b/frontend/src/views/RecipeList.vue @@ -11,12 +11,12 @@ import Skeleton from '../components/Skeleton.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; import TagsSelect from '../components/TagsSelect.vue'; import { iconAdd, iconNoRecipe, iconRecipe } from '../icons'; -import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api'; +import { api, getAuthToken, type AuthUser, type Item, type ListPage, type Options } from '../services/api'; import RecipeEdit from './RecipeEdit.vue'; const options = ref(null); const route = useRoute(); -const { t } = useI18n(); +const { t, locale } = useI18n(); const items = ref([]); const currentUser = ref(null); const loading = ref(true); @@ -46,6 +46,40 @@ const itemQuery = computed(() => ({ tagIds: tagIds.value.join(','), recipeOrder: 1 })); + +type RecipeListInitialData = { + options: Options | null; + page: ListPage | null; +}; + +const { data: initialData } = await useAsyncData( + `recipe-list-initial:${locale.value}`, + async () => { + const [optionsResult, itemsResult] = await Promise.allSettled([ + api.options(), + api.itemsPage({ + ...itemQuery.value, + cursor: null, + limit: listPageSize + }) + ]); + + return { + options: optionsResult.status === 'fulfilled' ? optionsResult.value : null, + page: itemsResult.status === 'fulfilled' ? itemsResult.value : null + }; + }, + { default: () => ({ options: null, page: null }) } +); + +const initialPage = initialData.value?.page ?? null; +options.value = initialData.value?.options ?? null; +items.value = initialPage?.items ?? []; +const initialPageLoaded = ref(initialPage !== null); +loading.value = !initialPageLoaded.value; +nextCursor.value = initialPage?.nextCursor ?? null; +hasMoreItems.value = initialPage?.hasMore ?? false; + const showEditor = computed(() => route.name === 'recipe-new'); const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true); @@ -103,6 +137,14 @@ async function loadItems(reset = true) { } nextCursor.value = page.nextCursor; hasMoreItems.value = page.hasMore; + initialPageLoaded.value = true; + } catch { + if (requestId === loadRequestId && reset) { + items.value = []; + nextCursor.value = null; + hasMoreItems.value = false; + initialPageLoaded.value = true; + } } finally { if (requestId === loadRequestId) { loading.value = false; @@ -123,8 +165,16 @@ onMounted(async () => { currentUser.value = null; } } - options.value = await api.options(); - await loadItems(); + if (!options.value) { + try { + options.value = await api.options(); + } catch { + options.value = null; + } + } + if (!initialPageLoaded.value) { + await loadItems(); + } }); watch(itemQuery, () => {