From f92e97b747597b20422fba15d1646efa296bd7ae Mon Sep 17 00:00:00 2001 From: xiaomai Date: Wed, 6 May 2026 12:01:00 +0800 Subject: [PATCH] feat(ssr): load initial data and SEO for public detail pages Fetch initial content server-side for detail views and Life feed. Bind detail-specific SEO head tags during SSR. Extract resolvedSeoHead to share head tag generation. --- SSR_MIGRATION_TASKLIST.md | 4 ++ frontend/plugins/02-seo.ts | 35 +------------- frontend/src/seo.ts | 33 +++++++++++++ frontend/src/views/HabitatDetail.vue | 59 ++++++++++++++++++----- frontend/src/views/ItemDetail.vue | 66 ++++++++++++++++++++------ frontend/src/views/LifePostDetail.vue | 56 +++++++++++++++++++++- frontend/src/views/LifeView.vue | 62 ++++++++++++++++++++++-- frontend/src/views/PokemonDetail.vue | 63 +++++++++++++++++++----- frontend/src/views/RecipeDetail.vue | 61 +++++++++++++++++++----- frontend/src/views/UserProfileView.vue | 50 ++++++++++++++++++- 10 files changed, 396 insertions(+), 93 deletions(-) diff --git a/SSR_MIGRATION_TASKLIST.md b/SSR_MIGRATION_TASKLIST.md index 3b4eda4..b599fa8 100644 --- a/SSR_MIGRATION_TASKLIST.md +++ b/SSR_MIGRATION_TASKLIST.md @@ -99,7 +99,9 @@ Keep this file aligned with implementation progress while the SSR migration is i - [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. + - [x] Public detail pages can now override route-level head from SSR-loaded public business data when the corresponding API read succeeds. - [ ] For detail pages, use entity names, public images, localized public fields, and canonical detail URLs after public API data loads server-side. + - [x] Pokemon, Habitat, Item, Ancient Artifact, Recipe, Life Post, and public Profile detail routes SSR-load public data and bind detail-specific head tags. - [ ] 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`. @@ -113,6 +115,7 @@ Keep this file aligned with implementation progress while the SSR migration is i - 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. +- Pokemon, Habitat, Item, Ancient Artifact, Recipe, Life Post, and public Profile detail views now use contained SSR public reads for initial content and detail-specific SEO head output. Auth-only permissions, profile account data, reactions, comments, follow state, editor controls, and moderation actions remain client-hydrated. - 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 @@ -159,6 +162,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. +- 2026-05-06: After public detail SSR data and head expansion, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. Local built-server smoke on port `20117` verified `robots.txt` and `sitemap.xml`; business-data HTML verification still needs Docker/backend runtime because the local Nuxt server could not reach `localhost:3001`. ## Phase 9: Cleanup diff --git a/frontend/plugins/02-seo.ts b/frontend/plugins/02-seo.ts index e886f93..417d9bc 100644 --- a/frontend/plugins/02-seo.ts +++ b/frontend/plugins/02-seo.ts @@ -1,6 +1,6 @@ import { computed, ref } from 'vue'; import { onLocaleChange } from '../src/i18n'; -import { applyRouteSeo, onSeoChange, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo'; +import { applyRouteSeo, onSeoChange, resolvedSeoHead, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo'; export default defineNuxtPlugin(() => { const router = useRouter(); @@ -8,38 +8,7 @@ export default defineNuxtPlugin(() => { const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record) => string } }).global.t; const dynamicSeo = ref(null); const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t)); - const structuredDataJson = computed(() => JSON.stringify(activeSeo.value.structuredData).replace(/ ({ - 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', - innerHTML: structuredDataJson.value - } - ] - })); + useHead(() => resolvedSeoHead(activeSeo.value)); if (import.meta.server) { return; diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts index 9f9eb70..166859a 100644 --- a/frontend/src/seo.ts +++ b/frontend/src/seo.ts @@ -143,6 +143,39 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig { }; } +export function resolvedSeoHead(seo: ResolvedSeoConfig) { + return { + title: seo.title, + htmlAttrs: { + lang: seo.locale + }, + meta: [ + { key: 'description', name: 'description', content: seo.description }, + { key: 'robots', name: 'robots', content: seo.robots }, + { key: 'twitter-card', name: 'twitter:card', content: 'summary_large_image' }, + { key: 'twitter-title', name: 'twitter:title', content: seo.title }, + { key: 'twitter-description', name: 'twitter:description', content: seo.description }, + { key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl }, + { key: 'og-site-name', property: 'og:site_name', content: siteName }, + { key: 'og-type', property: 'og:type', content: 'website' }, + { key: 'og-title', property: 'og:title', content: seo.title }, + { key: 'og-description', property: 'og:description', content: seo.description }, + { key: 'og-url', property: 'og:url', content: seo.canonicalUrl }, + { key: 'og-image', property: 'og:image', content: seo.imageUrl }, + { key: 'og-locale', property: 'og:locale', content: seo.locale === 'en' ? 'en_US' : seo.locale.replace('-', '_') } + ], + link: [{ key: 'canonical', rel: 'canonical', href: seo.canonicalUrl }], + script: [ + { + key: 'pokopia-structured-data', + id: 'pokopia-structured-data', + type: 'application/ld+json', + innerHTML: JSON.stringify(seo.structuredData).replace(/(null); const currentUser = ref(null); const detailTab = ref('details'); @@ -33,6 +33,33 @@ const detailTabs = computed(() => [ { value: 'history', label: t('history.editHistory') } ]); +const { data: initialHabitat } = await useAsyncData( + `habitat-detail:${String(route.params.id)}:${locale.value}`, + async () => { + try { + return await api.habitatDetail(String(route.params.id)); + } catch { + return null; + } + }, + { default: () => null } +); + +habitat.value = initialHabitat.value; +const initialHabitatLoaded = ref(initialHabitat.value !== null); +const habitatSeo = computed(() => + habitat.value && route.meta.editorModal !== true + ? resolveSeo({ + title: `${habitat.value.name} - ${t(habitat.value.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`, + description: t('seo.habitatDetailDescription', { name: habitat.value.name }), + canonicalPath: `/habitats/${habitat.value.id}`, + image: habitat.value.image?.url + }) + : null +); + +useHead(() => (habitatSeo.value ? resolvedSeoHead(habitatSeo.value) : {})); + type PokemonRow = { id: number; name: string; @@ -119,16 +146,22 @@ const pokemonRows = computed(() => { }); async function loadHabitatDetail() { - const nextHabitat = await api.habitatDetail(String(route.params.id)); - habitat.value = nextHabitat; + try { + const nextHabitat = await api.habitatDetail(String(route.params.id)); + habitat.value = nextHabitat; + initialHabitatLoaded.value = true; - if (route.meta.editorModal !== true) { - applySeo({ - title: `${nextHabitat.name} - ${t(nextHabitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`, - description: t('seo.habitatDetailDescription', { name: nextHabitat.name }), - canonicalPath: `/habitats/${nextHabitat.id}`, - image: nextHabitat.image?.url - }); + if (route.meta.editorModal !== true) { + applySeo({ + title: `${nextHabitat.name} - ${t(nextHabitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`, + description: t('seo.habitatDetailDescription', { name: nextHabitat.name }), + canonicalPath: `/habitats/${nextHabitat.id}`, + image: nextHabitat.image?.url + }); + } + } catch { + habitat.value = null; + initialHabitatLoaded.value = true; } } @@ -140,7 +173,9 @@ onMounted(async () => { currentUser.value = null; } } - await loadHabitatDetail(); + if (!initialHabitatLoaded.value) { + await loadHabitatDetail(); + } }); watch( diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index 41b0277..2d0da08 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -12,7 +12,7 @@ import PokeBallMark from '../components/PokeBallMark.vue'; import Skeleton from '../components/Skeleton.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons'; -import { applySeo } from '../seo'; +import { applySeo, resolvedSeoHead, resolveSeo } from '../seo'; import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api'; import ItemEdit from './ItemEdit.vue'; @@ -73,6 +73,34 @@ const possibleTagEvidenceSections = computed(() => [ { key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] } ]); +const { data: initialItem } = await useAsyncData( + `item-detail:${String(route.name)}:${String(route.params.id)}:${locale.value}`, + async () => { + try { + const nextItem = await api.itemDetail(String(route.params.id)); + return isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory ? null : nextItem; + } catch { + return null; + } + }, + { default: () => null } +); + +item.value = initialItem.value; +const initialItemLoaded = ref(initialItem.value !== null); +const itemSeo = computed(() => + item.value && route.meta.editorModal !== true + ? resolveSeo({ + title: `${item.value.name} - ${t(detailTitleKey.value)}`, + description: t(detailDescriptionKey.value, { name: item.value.name }), + canonicalPath: detailCanonicalPath.value, + image: item.value.image?.url + }) + : null +); + +useHead(() => (itemSeo.value ? resolvedSeoHead(itemSeo.value) : {})); + const customization = computed(() => { if (!item.value) { return []; @@ -86,22 +114,28 @@ const customization = computed(() => { }); async function loadItemDetail() { - const nextItem = await api.itemDetail(String(route.params.id)); + try { + const nextItem = await api.itemDetail(String(route.params.id)); - if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) { - await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`); - return; - } + if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) { + await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`); + return; + } - item.value = nextItem; + item.value = nextItem; + initialItemLoaded.value = true; - if (route.meta.editorModal !== true) { - applySeo({ - title: `${nextItem.name} - ${t(detailTitleKey.value)}`, - description: t(detailDescriptionKey.value, { name: nextItem.name }), - canonicalPath: detailCanonicalPath.value, - image: nextItem.image?.url - }); + if (route.meta.editorModal !== true) { + applySeo({ + title: `${nextItem.name} - ${t(detailTitleKey.value)}`, + description: t(detailDescriptionKey.value, { name: nextItem.name }), + canonicalPath: detailCanonicalPath.value, + image: nextItem.image?.url + }); + } + } catch { + item.value = null; + initialItemLoaded.value = true; } } @@ -117,7 +151,9 @@ onMounted(async () => { currentUser.value = null; } } - await loadItemDetail(); + if (!initialItemLoaded.value) { + await loadItemDetail(); + } }); watch( diff --git a/frontend/src/views/LifePostDetail.vue b/frontend/src/views/LifePostDetail.vue index 2442c0a..358294a 100644 --- a/frontend/src/views/LifePostDetail.vue +++ b/frontend/src/views/LifePostDetail.vue @@ -40,6 +40,7 @@ import { type LifeReactionType, type ModerationUpdateDetail } from '../services/api'; +import { resolvedSeoHead, resolveSeo } from '../seo'; const { locale, t } = useI18n(); const route = useRoute(); @@ -101,6 +102,15 @@ function routePostId() { return Array.isArray(value) ? value[0] : value; } +function summaryText(value: string, maxLength: number) { + const normalized = value.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}...`; +} + async function loadCurrentUser() { if (!getAuthToken()) { currentUser.value = null; @@ -133,6 +143,41 @@ function resetCommentsFromPost(nextPost: LifePost) { commentErrors.value = {}; } +const { data: initialPost } = await useAsyncData( + `life-post-detail:${String(routePostId())}:${locale.value}`, + async () => { + const id = routePostId(); + if (!id) { + return null; + } + + try { + return await api.lifePost(id); + } catch { + return null; + } + }, + { default: () => null } +); + +if (initialPost.value) { + post.value = initialPost.value; + resetCommentsFromPost(initialPost.value); +} +const initialPostLoaded = ref(initialPost.value !== null); +loading.value = !initialPostLoaded.value; +const postSeo = computed(() => + post.value + ? resolveSeo({ + title: `${summaryText(post.value.body, 64) || t('pages.life.detailTitle')} - ${t('pages.life.title')}`, + description: summaryText(post.value.body, 155) || t('pages.life.detailSubtitle'), + canonicalPath: `/life/${post.value.id}` + }) + : null +); + +useHead(() => (postSeo.value ? resolvedSeoHead(postSeo.value) : {})); + async function loadPost() { const id = routePostId(); if (!id) { @@ -147,9 +192,11 @@ async function loadPost() { const nextPost = await api.lifePost(id); post.value = nextPost; resetCommentsFromPost(nextPost); + initialPostLoaded.value = true; void loadComments(true); } catch (error) { loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed'); + initialPostLoaded.value = true; } finally { loading.value = false; } @@ -793,8 +840,13 @@ onMounted(() => { document.addEventListener('click', closeReactionPickerFromDocument); document.addEventListener('keydown', closeReactionPickerFromKeyboard); window.addEventListener(moderationUpdateEvent, handleModerationUpdate); - void loadCurrentUser(); - void loadPost(); + const hadAuthToken = getAuthToken() !== null; + void (async () => { + await loadCurrentUser(); + if (!initialPostLoaded.value || hadAuthToken) { + await loadPost(); + } + })(); removeAuthListener = onAuthTokenChange(() => { void loadCurrentUser(); void loadPost(); diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index bf3494e..14400a1 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -47,6 +47,7 @@ import { type LifeCategory, type LifeComment, type LifePost, + type LifePostsPage, type LifeReactionType, type ModerationUpdateDetail } from '../services/api'; @@ -124,6 +125,47 @@ const allCategoryValue = 'all'; const allLanguageValue = 'all'; const allGameVersionValue = 'all'; +type LifeInitialData = { + options: { lifeCategories: LifeCategory[]; gameVersions: GameVersion[] } | null; + languages: Language[] | null; + posts: LifePostsPage | null; +}; + +const { data: initialData } = await useAsyncData( + `life-feed-initial:${locale.value}`, + async () => { + const [optionsResult, languagesResult, postsResult] = await Promise.allSettled([ + api.options(), + api.languages(), + api.lifePosts({ + limit: lifePostPageSize, + sort: 'latest' + }) + ]); + + return { + options: + optionsResult.status === 'fulfilled' + ? { lifeCategories: optionsResult.value.lifeCategories, gameVersions: optionsResult.value.gameVersions } + : null, + languages: languagesResult.status === 'fulfilled' ? languagesResult.value.filter((language) => language.enabled) : null, + posts: postsResult.status === 'fulfilled' ? postsResult.value : null + }; + }, + { default: () => ({ options: null, languages: null, posts: null }) } +); + +lifeCategories.value = initialData.value.options?.lifeCategories ?? []; +gameVersions.value = initialData.value.options?.gameVersions ?? []; +languages.value = initialData.value.languages ?? []; +posts.value = initialData.value.posts?.items ?? []; +nextCursor.value = initialData.value.posts?.nextCursor ?? null; +hasMorePosts.value = initialData.value.posts?.hasMore ?? false; +const initialOptionsLoaded = ref(initialData.value.options !== null); +const initialLanguagesLoaded = ref(initialData.value.languages !== null); +const initialPostsLoaded = ref(initialData.value.posts !== null); +loading.value = !initialPostsLoaded.value; + const reactionOptions = [ { type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' }, { type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' }, @@ -1334,10 +1376,22 @@ onMounted(() => { document.addEventListener('click', closeReactionPickerFromDocument); document.addEventListener('keydown', closeReactionPickerFromKeyboard); window.addEventListener(moderationUpdateEvent, handleModerationUpdate); - void loadCurrentUser(); - void loadLanguages(); - void loadLifeCategories(); - void loadPosts(); + const hadAuthToken = getAuthToken() !== null; + void (async () => { + await loadCurrentUser(); + if (!initialLanguagesLoaded.value) { + await loadLanguages(); + initialLanguagesLoaded.value = true; + } + if (!initialOptionsLoaded.value) { + await loadLifeCategories(); + initialOptionsLoaded.value = true; + } + if (!initialPostsLoaded.value || hadAuthToken) { + await loadPosts(); + initialPostsLoaded.value = true; + } + })(); removeAuthListener = onAuthTokenChange(() => { void (async () => { await loadCurrentUser(); diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index 4f7413d..a552613 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -15,12 +15,12 @@ import Skeleton from '../components/Skeleton.vue'; import StatusMessage from '../components/StatusMessage.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons'; -import { applySeo } from '../seo'; +import { applySeo, resolvedSeoHead, resolveSeo } from '../seo'; import { api, getAuthToken, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api'; import PokemonEdit from './PokemonEdit.vue'; const route = useRoute(); -const { t } = useI18n(); +const { t, locale } = useI18n(); const pokemon = ref(null); const currentUser = ref(null); const itemCategoryTab = ref(''); @@ -40,6 +40,34 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const weathers = ['晴天', '阴天', '雨天']; const relatedPokemonLimit = 6; +const { data: initialPokemon } = await useAsyncData( + `pokemon-detail:${String(route.params.id)}:${locale.value}`, + async () => { + try { + return await api.pokemonDetail(String(route.params.id)); + } catch { + return null; + } + }, + { default: () => null } +); + +pokemon.value = initialPokemon.value; +relatedHabitatTab.value = initialPokemon.value ? habitatTabValue(initialPokemon.value.environment.id) : ''; +const initialPokemonLoaded = ref(initialPokemon.value !== null); +const pokemonSeo = computed(() => + pokemon.value && route.meta.editorModal !== true + ? resolveSeo({ + title: `${pokemon.value.name} - ${t(pokemon.value.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`, + description: t('seo.pokemonDetailDescription', { name: pokemon.value.name }), + canonicalPath: `/pokemon/${pokemon.value.id}`, + image: pokemon.value.image?.url + }) + : null +); + +useHead(() => (pokemonSeo.value ? resolvedSeoHead(pokemonSeo.value) : {})); + type HabitatRow = { id: number; name: string; @@ -411,17 +439,24 @@ async function saveTradingItems() { } async function loadPokemonDetail() { - const nextPokemon = await api.pokemonDetail(String(route.params.id)); - pokemon.value = nextPokemon; - relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id); + try { + const nextPokemon = await api.pokemonDetail(String(route.params.id)); + pokemon.value = nextPokemon; + relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id); + initialPokemonLoaded.value = true; - if (route.meta.editorModal !== true) { - applySeo({ - title: `${nextPokemon.name} - ${t(nextPokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`, - description: t('seo.pokemonDetailDescription', { name: nextPokemon.name }), - canonicalPath: `/pokemon/${nextPokemon.id}`, - image: nextPokemon.image?.url - }); + if (route.meta.editorModal !== true) { + applySeo({ + title: `${nextPokemon.name} - ${t(nextPokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`, + description: t('seo.pokemonDetailDescription', { name: nextPokemon.name }), + canonicalPath: `/pokemon/${nextPokemon.id}`, + image: nextPokemon.image?.url + }); + } + } catch { + pokemon.value = null; + relatedHabitatTab.value = ''; + initialPokemonLoaded.value = true; } } @@ -433,7 +468,9 @@ onMounted(async () => { currentUser.value = null; } } - await loadPokemonDetail(); + if (!initialPokemonLoaded.value) { + await loadPokemonDetail(); + } }); watch( diff --git a/frontend/src/views/RecipeDetail.vue b/frontend/src/views/RecipeDetail.vue index 03f6ae2..f83f4f2 100644 --- a/frontend/src/views/RecipeDetail.vue +++ b/frontend/src/views/RecipeDetail.vue @@ -11,12 +11,12 @@ import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; import { iconBack, iconEdit, iconRecipe } from '../icons'; -import { applySeo } from '../seo'; +import { applySeo, resolvedSeoHead, resolveSeo } from '../seo'; import { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api'; import RecipeEdit from './RecipeEdit.vue'; const route = useRoute(); -const { t } = useI18n(); +const { t, locale } = useI18n(); const recipe = ref(null); const currentUser = ref(null); const detailTab = ref('details'); @@ -42,17 +42,50 @@ const recipeSubtitle = computed(() => { return categoryName ?? t('pages.recipes.detailSubtitle'); }); -async function loadRecipeDetail() { - const nextRecipe = await api.recipeDetail(String(route.params.id)); - recipe.value = nextRecipe; +const { data: initialRecipe } = await useAsyncData( + `recipe-detail:${String(route.params.id)}:${locale.value}`, + async () => { + try { + return await api.recipeDetail(String(route.params.id)); + } catch { + return null; + } + }, + { default: () => null } +); - if (route.meta.editorModal !== true) { - applySeo({ - title: `${nextRecipe.name} - ${t('pages.recipes.title')}`, - description: t('seo.recipeDetailDescription', { name: nextRecipe.name }), - canonicalPath: `/recipes/${nextRecipe.id}`, - image: nextRecipe.item.image?.url - }); +recipe.value = initialRecipe.value; +const initialRecipeLoaded = ref(initialRecipe.value !== null); +const recipeSeo = computed(() => + recipe.value && route.meta.editorModal !== true + ? resolveSeo({ + title: `${recipe.value.name} - ${t('pages.recipes.title')}`, + description: t('seo.recipeDetailDescription', { name: recipe.value.name }), + canonicalPath: `/recipes/${recipe.value.id}`, + image: recipe.value.item.image?.url + }) + : null +); + +useHead(() => (recipeSeo.value ? resolvedSeoHead(recipeSeo.value) : {})); + +async function loadRecipeDetail() { + try { + const nextRecipe = await api.recipeDetail(String(route.params.id)); + recipe.value = nextRecipe; + initialRecipeLoaded.value = true; + + if (route.meta.editorModal !== true) { + applySeo({ + title: `${nextRecipe.name} - ${t('pages.recipes.title')}`, + description: t('seo.recipeDetailDescription', { name: nextRecipe.name }), + canonicalPath: `/recipes/${nextRecipe.id}`, + image: nextRecipe.item.image?.url + }); + } + } catch { + recipe.value = null; + initialRecipeLoaded.value = true; } } @@ -64,7 +97,9 @@ onMounted(async () => { currentUser.value = null; } } - await loadRecipeDetail(); + if (!initialRecipeLoaded.value) { + await loadRecipeDetail(); + } }); watch( diff --git a/frontend/src/views/UserProfileView.vue b/frontend/src/views/UserProfileView.vue index 4061ece..ad200a9 100644 --- a/frontend/src/views/UserProfileView.vue +++ b/frontend/src/views/UserProfileView.vue @@ -32,6 +32,7 @@ import { type AuthUser, type DiscussionEntityType, type LifePost, + type LifePostsPage, type LifeReactionType, type ProfileCommentSource, type PublicUserProfile, @@ -39,6 +40,7 @@ import { type UserCommentActivity, type UserReactionActivity } from '../services/api'; +import { resolvedSeoHead, resolveSeo } from '../seo'; type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account'; type PrimaryContributionFilter = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats' | 'daily-checklist'; @@ -199,6 +201,48 @@ const socialStats = computed(() => { { label: t('pages.profile.friends'), value: social?.friendCount ?? 0 } ]; }); + +type PublicProfileInitialData = { + profile: PublicUserProfile | null; + feeds: LifePostsPage | null; +}; + +const { data: initialPublicProfile } = await useAsyncData( + `public-profile:${String(routeProfileId.value ?? '')}:${locale.value}`, + async () => { + const targetId = routeProfileId.value; + if (!targetId) { + return { profile: null, feeds: null }; + } + + const profileResult = await Promise.allSettled([api.publicProfile(targetId), api.userLifePosts(targetId, { limit: activityLimit })]); + return { + profile: profileResult[0].status === 'fulfilled' ? profileResult[0].value.profile : null, + feeds: profileResult[1].status === 'fulfilled' ? profileResult[1].value : null + }; + }, + { default: () => ({ profile: null, feeds: null }) } +); + +profile.value = initialPublicProfile.value.profile; +feeds.value = initialPublicProfile.value.feeds?.items ?? []; +feedsCursor.value = initialPublicProfile.value.feeds?.nextCursor ?? null; +feedsHasMore.value = initialPublicProfile.value.feeds?.hasMore ?? false; +const initialPublicProfileLoaded = ref(initialPublicProfile.value.profile !== null); +const initialFeedsLoaded = ref(initialPublicProfile.value.feeds !== null); +loading.value = !initialPublicProfileLoaded.value; +const profileSeo = computed(() => + profile.value && !isAccountRoute.value + ? resolveSeo({ + title: `${profile.value.user.displayName} - ${t('pages.profile.title')}`, + description: t('pages.profile.publicSubtitle'), + canonicalPath: `/profile/${profile.value.user.id}` + }) + : null +); + +useHead(() => (profileSeo.value ? resolvedSeoHead(profileSeo.value) : {})); + const filteredContributions = computed(() => { const items = profile.value?.contributions ?? []; if (contributionFilter.value === 'all') { @@ -679,7 +723,11 @@ function commentTargetTitle(comment: UserCommentActivity): string { } onMounted(() => { - void loadProfile(); + if (isAccountRoute.value || getAuthToken() || !initialPublicProfileLoaded.value) { + void loadProfile(); + } else if (!initialFeedsLoaded.value) { + void loadFeeds(true); + } });