Compare commits
2 Commits
f7986ca520
...
f92e97b747
| Author | SHA1 | Date | |
|---|---|---|---|
| f92e97b747 | |||
| d66124862a |
@@ -93,9 +93,15 @@ 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.
|
- [ ] 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] 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.
|
- [ ] 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] 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.
|
- [ ] 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.
|
- [ ] 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`.
|
- [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`.
|
- [ ] Keep `robots.txt` and `sitemap.xml` generated from the same stable public route set documented in `DESIGN.md`.
|
||||||
@@ -108,6 +114,8 @@ 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 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.
|
- 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.
|
- 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.
|
- 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
|
## Phase 6: Browser-Only UI Isolation
|
||||||
@@ -153,6 +161,8 @@ 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 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 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
|
## Phase 9: Cleanup
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { onLocaleChange } from '../src/i18n';
|
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(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
const router = useRouter();
|
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 t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||||
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
|
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
|
||||||
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
|
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
|
||||||
const structuredDataJson = computed(() => JSON.stringify(activeSeo.value.structuredData).replace(/</g, '\\u003C'));
|
useHead(() => resolvedSeoHead(activeSeo.value));
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (import.meta.server) {
|
if (import.meta.server) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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 {
|
export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
|
||||||
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
|
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
|
||||||
const canonicalPath =
|
const canonicalPath =
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import Skeleton from '../components/Skeleton.vue';
|
|||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { iconAdd, iconArtifact } from '../icons';
|
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';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const artifacts = ref<AncientArtifact[]>([]);
|
const artifacts = ref<AncientArtifact[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
@@ -42,6 +42,40 @@ const artifactQuery = computed(() => ({
|
|||||||
categoryId: categoryId.value,
|
categoryId: categoryId.value,
|
||||||
tagIds: tagIds.value.join(',')
|
tagIds: tagIds.value.join(',')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
type AncientArtifactListInitialData = {
|
||||||
|
options: Options | null;
|
||||||
|
page: ListPage<AncientArtifact> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: initialData } = await useAsyncData<AncientArtifactListInitialData>(
|
||||||
|
`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 showEditor = computed(() => route.name === 'ancient-artifact-new');
|
||||||
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||||
|
|
||||||
@@ -83,6 +117,14 @@ async function loadArtifacts(reset = true) {
|
|||||||
}
|
}
|
||||||
nextCursor.value = page.nextCursor;
|
nextCursor.value = page.nextCursor;
|
||||||
hasMoreArtifacts.value = page.hasMore;
|
hasMoreArtifacts.value = page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
} catch {
|
||||||
|
if (requestId === loadRequestId && reset) {
|
||||||
|
artifacts.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreArtifacts.value = false;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === loadRequestId) {
|
if (requestId === loadRequestId) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -103,8 +145,16 @@ onMounted(async () => {
|
|||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!options.value) {
|
||||||
|
try {
|
||||||
options.value = await api.options();
|
options.value = await api.options();
|
||||||
|
} catch {
|
||||||
|
options.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!initialPageLoaded.value) {
|
||||||
await loadArtifacts();
|
await loadArtifacts();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(artifactQuery, () => {
|
watch(artifactQuery, () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.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 = {
|
type ChecklistState = {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -12,7 +12,7 @@ type ChecklistState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checklistStateKey = 'pokopia_daily_checklist_state';
|
const checklistStateKey = 'pokopia_daily_checklist_state';
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const stateRefreshIntervalMs = 60_000;
|
const stateRefreshIntervalMs = 60_000;
|
||||||
const checklistItems = ref<DailyChecklistItem[]>([]);
|
const checklistItems = ref<DailyChecklistItem[]>([]);
|
||||||
const checkedTaskIds = ref<Set<number>>(new Set());
|
const checkedTaskIds = ref<Set<number>>(new Set());
|
||||||
@@ -25,6 +25,28 @@ const listPageSize = 20;
|
|||||||
let stateRefreshTimer: number | null = null;
|
let stateRefreshTimer: number | null = null;
|
||||||
let loadRequestId = 0;
|
let loadRequestId = 0;
|
||||||
|
|
||||||
|
const { data: initialData } = await useAsyncData<ListPage<DailyChecklistItem> | 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() {
|
function todayKey() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const year = today.getFullYear();
|
const year = today.getFullYear();
|
||||||
@@ -124,9 +146,17 @@ async function loadDailyChecklist(reset = true) {
|
|||||||
}
|
}
|
||||||
nextCursor.value = page.nextCursor;
|
nextCursor.value = page.nextCursor;
|
||||||
hasMoreItems.value = page.hasMore;
|
hasMoreItems.value = page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
if (!page.hasMore) {
|
if (!page.hasMore) {
|
||||||
syncChecklistState();
|
syncChecklistState();
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestId === loadRequestId && reset) {
|
||||||
|
checklistItems.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreItems.value = false;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === loadRequestId) {
|
if (requestId === loadRequestId) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -141,8 +171,13 @@ function loadMoreDailyChecklist() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadChecklistState();
|
loadChecklistState();
|
||||||
|
if (initialPageLoaded.value && !hasMoreItems.value) {
|
||||||
|
syncChecklistState();
|
||||||
|
}
|
||||||
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
|
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
|
||||||
|
if (!initialPageLoaded.value) {
|
||||||
void loadDailyChecklist();
|
void loadDailyChecklist();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
type TranslationMap
|
type TranslationMap
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const categories = ref<DishCategory[]>([]);
|
const categories = ref<DishCategory[]>([]);
|
||||||
const activeCategoryId = ref('');
|
const activeCategoryId = ref('');
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -96,6 +96,24 @@ const dishFormValid = computed(
|
|||||||
dishForm.value.mosslaxEffect.trim() !== ''
|
dishForm.value.mosslaxEffect.trim() !== ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: initialData } = await useAsyncData<DishCategory[] | null>(
|
||||||
|
`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) {
|
function itemImage(item: ItemLink) {
|
||||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : null;
|
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();
|
categories.value = await api.dish();
|
||||||
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
|
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
|
||||||
|
initialCategoriesLoaded.value = true;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +308,7 @@ async function loadPage() {
|
|||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await Promise.all([loadDish(), loadEditorOptions()]);
|
await Promise.all([initialCategoriesLoaded.value ? Promise.resolve() : loadDish(), loadEditorOptions()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(categories, (nextCategories) => {
|
watch(categories, (nextCategories) => {
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
|||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconBack, iconEdit, iconHabitat } from '../icons';
|
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 { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
|
||||||
import HabitatEdit from './HabitatEdit.vue';
|
import HabitatEdit from './HabitatEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const habitat = ref<HabitatDetail | null>(null);
|
const habitat = ref<HabitatDetail | null>(null);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const detailTab = ref('details');
|
const detailTab = ref('details');
|
||||||
@@ -33,6 +33,33 @@ const detailTabs = computed<TabOption[]>(() => [
|
|||||||
{ value: 'history', label: t('history.editHistory') }
|
{ 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 = {
|
type PokemonRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -119,8 +146,10 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadHabitatDetail() {
|
async function loadHabitatDetail() {
|
||||||
|
try {
|
||||||
const nextHabitat = await api.habitatDetail(String(route.params.id));
|
const nextHabitat = await api.habitatDetail(String(route.params.id));
|
||||||
habitat.value = nextHabitat;
|
habitat.value = nextHabitat;
|
||||||
|
initialHabitatLoaded.value = true;
|
||||||
|
|
||||||
if (route.meta.editorModal !== true) {
|
if (route.meta.editorModal !== true) {
|
||||||
applySeo({
|
applySeo({
|
||||||
@@ -130,6 +159,10 @@ async function loadHabitatDetail() {
|
|||||||
image: nextHabitat.image?.url
|
image: nextHabitat.image?.url
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
habitat.value = null;
|
||||||
|
initialHabitatLoaded.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -140,7 +173,9 @@ onMounted(async () => {
|
|||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!initialHabitatLoaded.value) {
|
||||||
await loadHabitatDetail();
|
await loadHabitatDetail();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
|||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import { iconAdd, iconHabitat } from '../icons';
|
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';
|
import HabitatEdit from './HabitatEdit.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -18,8 +18,7 @@ const props = defineProps<{
|
|||||||
const habitats = ref<Habitat[]>([]);
|
const habitats = ref<Habitat[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const loading = ref(true);
|
|
||||||
const loadingMore = ref(false);
|
const loadingMore = ref(false);
|
||||||
const nextCursor = ref<string | null>(null);
|
const nextCursor = ref<string | null>(null);
|
||||||
const hasMoreHabitats = ref(false);
|
const hasMoreHabitats = ref(false);
|
||||||
@@ -29,6 +28,30 @@ let loadRequestId = 0;
|
|||||||
const query = computed(() => ({
|
const query = computed(() => ({
|
||||||
isEventItem: props.eventOnly ? 'true' : 'false'
|
isEventItem: props.eventOnly ? 'true' : 'false'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { data: initialData } = await useAsyncData<ListPage<Habitat> | 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 showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new');
|
||||||
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
|
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
|
||||||
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.title' : 'pages.habitats.title'));
|
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;
|
nextCursor.value = page.nextCursor;
|
||||||
hasMoreHabitats.value = page.hasMore;
|
hasMoreHabitats.value = page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
} catch {
|
||||||
|
if (requestId === loadRequestId && reset) {
|
||||||
|
habitats.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreHabitats.value = false;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === loadRequestId) {
|
if (requestId === loadRequestId) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -95,7 +126,9 @@ onMounted(async () => {
|
|||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!initialPageLoaded.value) {
|
||||||
await loadHabitats();
|
await loadHabitats();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(query, () => {
|
watch(query, () => {
|
||||||
|
|||||||
@@ -63,8 +63,27 @@ const showProjectUpdates = computed(
|
|||||||
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
|
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
|
||||||
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||||
|
|
||||||
|
const { data: initialProjectUpdates } = await useAsyncData<ProjectUpdates | null>(
|
||||||
|
`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(() => {
|
onMounted(() => {
|
||||||
|
if (!initialProjectUpdatesLoaded.value) {
|
||||||
void loadProjectUpdates();
|
void loadProjectUpdates();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function sectionTitleKey(key: string) {
|
function sectionTitleKey(key: string) {
|
||||||
@@ -81,9 +100,11 @@ async function loadProjectUpdates(): Promise<void> {
|
|||||||
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||||
projectUpdates.value = updates;
|
projectUpdates.value = updates;
|
||||||
projectCommits.value = updates.commits.items;
|
projectCommits.value = updates.commits.items;
|
||||||
|
initialProjectUpdatesLoaded.value = true;
|
||||||
} catch {
|
} catch {
|
||||||
projectUpdates.value = null;
|
projectUpdates.value = null;
|
||||||
projectCommits.value = [];
|
projectCommits.value = [];
|
||||||
|
initialProjectUpdatesLoaded.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
projectUpdatesLoading.value = false;
|
projectUpdatesLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
|||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
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 { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
|
||||||
import ItemEdit from './ItemEdit.vue';
|
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 ?? [] }
|
{ 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(() => {
|
const customization = computed(() => {
|
||||||
if (!item.value) {
|
if (!item.value) {
|
||||||
return [];
|
return [];
|
||||||
@@ -86,6 +114,7 @@ const customization = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadItemDetail() {
|
async function loadItemDetail() {
|
||||||
|
try {
|
||||||
const nextItem = await api.itemDetail(String(route.params.id));
|
const nextItem = await api.itemDetail(String(route.params.id));
|
||||||
|
|
||||||
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
|
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
|
||||||
@@ -94,6 +123,7 @@ async function loadItemDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item.value = nextItem;
|
item.value = nextItem;
|
||||||
|
initialItemLoaded.value = true;
|
||||||
|
|
||||||
if (route.meta.editorModal !== true) {
|
if (route.meta.editorModal !== true) {
|
||||||
applySeo({
|
applySeo({
|
||||||
@@ -103,6 +133,10 @@ async function loadItemDetail() {
|
|||||||
image: nextItem.image?.url
|
image: nextItem.image?.url
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
item.value = null;
|
||||||
|
initialItemLoaded.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isItemDetailRouteName(value: unknown) {
|
function isItemDetailRouteName(value: unknown) {
|
||||||
@@ -117,7 +151,9 @@ onMounted(async () => {
|
|||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!initialItemLoaded.value) {
|
||||||
await loadItemDetail();
|
await loadItemDetail();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import Skeleton from '../components/Skeleton.vue';
|
|||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { iconAdd, iconChevronDown, iconChevronUp, iconItem } from '../icons';
|
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';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -21,7 +21,7 @@ const props = defineProps<{
|
|||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const items = ref<Item[]>([]);
|
const items = ref<Item[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -104,6 +104,40 @@ const itemQuery = computed(() => ({
|
|||||||
tagIds: tagIds.value.join(','),
|
tagIds: tagIds.value.join(','),
|
||||||
isEventItem: props.eventOnly
|
isEventItem: props.eventOnly
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
type ItemListInitialData = {
|
||||||
|
options: Options | null;
|
||||||
|
page: ListPage<Item> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: initialData } = await useAsyncData<ItemListInitialData>(
|
||||||
|
`${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 showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new');
|
||||||
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||||
const hasItemCreateDefaults = computed(
|
const hasItemCreateDefaults = computed(
|
||||||
@@ -458,6 +492,14 @@ async function loadItems(reset = true) {
|
|||||||
}
|
}
|
||||||
nextCursor.value = page.nextCursor;
|
nextCursor.value = page.nextCursor;
|
||||||
hasMoreItems.value = page.hasMore;
|
hasMoreItems.value = page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
} catch {
|
||||||
|
if (requestId === loadRequestId && reset) {
|
||||||
|
items.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreItems.value = false;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === loadRequestId) {
|
if (requestId === loadRequestId) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -480,9 +522,17 @@ onMounted(async () => {
|
|||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!options.value) {
|
||||||
|
try {
|
||||||
options.value = await api.options();
|
options.value = await api.options();
|
||||||
|
} catch {
|
||||||
|
options.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
sanitizeItemCreateDefaults();
|
sanitizeItemCreateDefaults();
|
||||||
|
if (!initialPageLoaded.value) {
|
||||||
await loadItems();
|
await loadItems();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
type LifeReactionType,
|
type LifeReactionType,
|
||||||
type ModerationUpdateDetail
|
type ModerationUpdateDetail
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
import { resolvedSeoHead, resolveSeo } from '../seo';
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -101,6 +102,15 @@ function routePostId() {
|
|||||||
return Array.isArray(value) ? value[0] : value;
|
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() {
|
async function loadCurrentUser() {
|
||||||
if (!getAuthToken()) {
|
if (!getAuthToken()) {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
@@ -133,6 +143,41 @@ function resetCommentsFromPost(nextPost: LifePost) {
|
|||||||
commentErrors.value = {};
|
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() {
|
async function loadPost() {
|
||||||
const id = routePostId();
|
const id = routePostId();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -147,9 +192,11 @@ async function loadPost() {
|
|||||||
const nextPost = await api.lifePost(id);
|
const nextPost = await api.lifePost(id);
|
||||||
post.value = nextPost;
|
post.value = nextPost;
|
||||||
resetCommentsFromPost(nextPost);
|
resetCommentsFromPost(nextPost);
|
||||||
|
initialPostLoaded.value = true;
|
||||||
void loadComments(true);
|
void loadComments(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
|
initialPostLoaded.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -793,8 +840,13 @@ onMounted(() => {
|
|||||||
document.addEventListener('click', closeReactionPickerFromDocument);
|
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||||
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
void loadCurrentUser();
|
const hadAuthToken = getAuthToken() !== null;
|
||||||
void loadPost();
|
void (async () => {
|
||||||
|
await loadCurrentUser();
|
||||||
|
if (!initialPostLoaded.value || hadAuthToken) {
|
||||||
|
await loadPost();
|
||||||
|
}
|
||||||
|
})();
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
removeAuthListener = onAuthTokenChange(() => {
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
void loadPost();
|
void loadPost();
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
type LifeCategory,
|
type LifeCategory,
|
||||||
type LifeComment,
|
type LifeComment,
|
||||||
type LifePost,
|
type LifePost,
|
||||||
|
type LifePostsPage,
|
||||||
type LifeReactionType,
|
type LifeReactionType,
|
||||||
type ModerationUpdateDetail
|
type ModerationUpdateDetail
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
@@ -124,6 +125,47 @@ const allCategoryValue = 'all';
|
|||||||
const allLanguageValue = 'all';
|
const allLanguageValue = 'all';
|
||||||
const allGameVersionValue = '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 = [
|
const reactionOptions = [
|
||||||
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
||||||
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
|
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
|
||||||
@@ -1334,10 +1376,22 @@ onMounted(() => {
|
|||||||
document.addEventListener('click', closeReactionPickerFromDocument);
|
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||||
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
void loadCurrentUser();
|
const hadAuthToken = getAuthToken() !== null;
|
||||||
void loadLanguages();
|
void (async () => {
|
||||||
void loadLifeCategories();
|
await loadCurrentUser();
|
||||||
void loadPosts();
|
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(() => {
|
removeAuthListener = onAuthTokenChange(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await loadCurrentUser();
|
await loadCurrentUser();
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import Skeleton from '../components/Skeleton.vue';
|
|||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons';
|
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 { api, getAuthToken, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api';
|
||||||
import PokemonEdit from './PokemonEdit.vue';
|
import PokemonEdit from './PokemonEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const pokemon = ref<PokemonDetail | null>(null);
|
const pokemon = ref<PokemonDetail | null>(null);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const itemCategoryTab = ref('');
|
const itemCategoryTab = ref('');
|
||||||
@@ -40,6 +40,34 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
|||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
const relatedPokemonLimit = 6;
|
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 = {
|
type HabitatRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -411,9 +439,11 @@ async function saveTradingItems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPokemonDetail() {
|
async function loadPokemonDetail() {
|
||||||
|
try {
|
||||||
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
||||||
pokemon.value = nextPokemon;
|
pokemon.value = nextPokemon;
|
||||||
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
|
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
|
||||||
|
initialPokemonLoaded.value = true;
|
||||||
|
|
||||||
if (route.meta.editorModal !== true) {
|
if (route.meta.editorModal !== true) {
|
||||||
applySeo({
|
applySeo({
|
||||||
@@ -423,6 +453,11 @@ async function loadPokemonDetail() {
|
|||||||
image: nextPokemon.image?.url
|
image: nextPokemon.image?.url
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
pokemon.value = null;
|
||||||
|
relatedHabitatTab.value = '';
|
||||||
|
initialPokemonLoaded.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -433,7 +468,9 @@ onMounted(async () => {
|
|||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!initialPokemonLoaded.value) {
|
||||||
await loadPokemonDetail();
|
await loadPokemonDetail();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -27,11 +27,33 @@ const loadMoreSentinel = ref<HTMLElement | null>(null);
|
|||||||
const expandedCommitShas = ref<Set<string>>(new Set());
|
const expandedCommitShas = ref<Set<string>>(new Set());
|
||||||
let projectUpdatesObserver: IntersectionObserver | null = null;
|
let projectUpdatesObserver: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
const { data: initialData } = await useAsyncData<ProjectUpdates | null>(
|
||||||
|
`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 releases = computed(() => projectUpdates.value?.releases ?? []);
|
||||||
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (!initialUpdatesLoaded.value) {
|
||||||
void loadProjectUpdates();
|
void loadProjectUpdates();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -53,9 +75,11 @@ async function loadProjectUpdates(): Promise<void> {
|
|||||||
projectCommits.value = updates.commits.items;
|
projectCommits.value = updates.commits.items;
|
||||||
projectCommitCursor.value = updates.commits.nextCursor;
|
projectCommitCursor.value = updates.commits.nextCursor;
|
||||||
projectHasMoreCommits.value = updates.commits.hasMore;
|
projectHasMoreCommits.value = updates.commits.hasMore;
|
||||||
|
initialUpdatesLoaded.value = true;
|
||||||
} catch {
|
} catch {
|
||||||
projectUpdates.value = null;
|
projectUpdates.value = null;
|
||||||
projectCommits.value = [];
|
projectCommits.value = [];
|
||||||
|
initialUpdatesLoaded.value = true;
|
||||||
loadError.value = true;
|
loadError.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import PageHeader from '../components/PageHeader.vue';
|
|||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconBack, iconEdit, iconRecipe } from '../icons';
|
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 { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api';
|
||||||
import RecipeEdit from './RecipeEdit.vue';
|
import RecipeEdit from './RecipeEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const recipe = ref<RecipeDetail | null>(null);
|
const recipe = ref<RecipeDetail | null>(null);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const detailTab = ref('details');
|
const detailTab = ref('details');
|
||||||
@@ -42,9 +42,38 @@ const recipeSubtitle = computed(() => {
|
|||||||
return categoryName ?? t('pages.recipes.detailSubtitle');
|
return categoryName ?? t('pages.recipes.detailSubtitle');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
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() {
|
async function loadRecipeDetail() {
|
||||||
|
try {
|
||||||
const nextRecipe = await api.recipeDetail(String(route.params.id));
|
const nextRecipe = await api.recipeDetail(String(route.params.id));
|
||||||
recipe.value = nextRecipe;
|
recipe.value = nextRecipe;
|
||||||
|
initialRecipeLoaded.value = true;
|
||||||
|
|
||||||
if (route.meta.editorModal !== true) {
|
if (route.meta.editorModal !== true) {
|
||||||
applySeo({
|
applySeo({
|
||||||
@@ -54,6 +83,10 @@ async function loadRecipeDetail() {
|
|||||||
image: nextRecipe.item.image?.url
|
image: nextRecipe.item.image?.url
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
recipe.value = null;
|
||||||
|
initialRecipeLoaded.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -64,7 +97,9 @@ onMounted(async () => {
|
|||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!initialRecipeLoaded.value) {
|
||||||
await loadRecipeDetail();
|
await loadRecipeDetail();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import Skeleton from '../components/Skeleton.vue';
|
|||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { iconAdd, iconNoRecipe, iconRecipe } from '../icons';
|
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';
|
import RecipeEdit from './RecipeEdit.vue';
|
||||||
|
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const items = ref<Item[]>([]);
|
const items = ref<Item[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -46,6 +46,40 @@ const itemQuery = computed(() => ({
|
|||||||
tagIds: tagIds.value.join(','),
|
tagIds: tagIds.value.join(','),
|
||||||
recipeOrder: 1
|
recipeOrder: 1
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
type RecipeListInitialData = {
|
||||||
|
options: Options | null;
|
||||||
|
page: ListPage<Item> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: initialData } = await useAsyncData<RecipeListInitialData>(
|
||||||
|
`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 showEditor = computed(() => route.name === 'recipe-new');
|
||||||
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
|
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
|
||||||
|
|
||||||
@@ -103,6 +137,14 @@ async function loadItems(reset = true) {
|
|||||||
}
|
}
|
||||||
nextCursor.value = page.nextCursor;
|
nextCursor.value = page.nextCursor;
|
||||||
hasMoreItems.value = page.hasMore;
|
hasMoreItems.value = page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
} catch {
|
||||||
|
if (requestId === loadRequestId && reset) {
|
||||||
|
items.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreItems.value = false;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === loadRequestId) {
|
if (requestId === loadRequestId) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -123,8 +165,16 @@ onMounted(async () => {
|
|||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!options.value) {
|
||||||
|
try {
|
||||||
options.value = await api.options();
|
options.value = await api.options();
|
||||||
|
} catch {
|
||||||
|
options.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!initialPageLoaded.value) {
|
||||||
await loadItems();
|
await loadItems();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(itemQuery, () => {
|
watch(itemQuery, () => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
type AuthUser,
|
type AuthUser,
|
||||||
type DiscussionEntityType,
|
type DiscussionEntityType,
|
||||||
type LifePost,
|
type LifePost,
|
||||||
|
type LifePostsPage,
|
||||||
type LifeReactionType,
|
type LifeReactionType,
|
||||||
type ProfileCommentSource,
|
type ProfileCommentSource,
|
||||||
type PublicUserProfile,
|
type PublicUserProfile,
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
type UserCommentActivity,
|
type UserCommentActivity,
|
||||||
type UserReactionActivity
|
type UserReactionActivity
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
import { resolvedSeoHead, resolveSeo } from '../seo';
|
||||||
|
|
||||||
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
|
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
|
||||||
type PrimaryContributionFilter = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats' | 'daily-checklist';
|
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 }
|
{ 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 filteredContributions = computed(() => {
|
||||||
const items = profile.value?.contributions ?? [];
|
const items = profile.value?.contributions ?? [];
|
||||||
if (contributionFilter.value === 'all') {
|
if (contributionFilter.value === 'all') {
|
||||||
@@ -679,7 +723,11 @@ function commentTargetTitle(comment: UserCommentActivity): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (isAccountRoute.value || getAuthToken() || !initialPublicProfileLoaded.value) {
|
||||||
void loadProfile();
|
void loadProfile();
|
||||||
|
} else if (!initialFeedsLoaded.value) {
|
||||||
|
void loadFeeds(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user