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.
This commit is contained in:
2026-05-06 12:01:00 +08:00
parent d66124862a
commit f92e97b747
10 changed files with 396 additions and 93 deletions

View File

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

View File

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

View File

@@ -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(/</g, '\\u003C')
}
]
};
}
export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
const canonicalPath =

View File

@@ -12,12 +12,12 @@ import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconHabitat } from '../icons';
import { applySeo } from '../seo';
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const route = useRoute();
const { t } = useI18n();
const { t, locale } = useI18n();
const habitat = ref<HabitatDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
@@ -33,6 +33,33 @@ const detailTabs = computed<TabOption[]>(() => [
{ value: 'history', label: t('history.editHistory') }
]);
const { data: initialHabitat } = await useAsyncData<HabitatDetail | null>(
`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<PokemonRow[]>(() => {
});
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(

View File

@@ -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<ItemDetail | null>(
`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(

View File

@@ -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<LifePost | null>(
`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();

View File

@@ -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<LifeInitialData>(
`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();

View File

@@ -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<PokemonDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const itemCategoryTab = ref('');
@@ -40,6 +40,34 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6;
const { data: initialPokemon } = await useAsyncData<PokemonDetail | null>(
`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(

View File

@@ -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<RecipeDetail | null>(null);
const currentUser = ref<AuthUser | null>(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<RecipeDetail | null>(
`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(

View File

@@ -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<PublicProfileInitialData>(
`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);
}
});
</script>