diff --git a/client/src/app/features/search/components/SearchResultsPage.tsx b/client/src/app/features/search/components/SearchResultsPage.tsx
index 54f83bf..c226b28 100644
--- a/client/src/app/features/search/components/SearchResultsPage.tsx
+++ b/client/src/app/features/search/components/SearchResultsPage.tsx
@@ -8,6 +8,9 @@ import { Pagination } from "@features/top/components/Pagination.tsx";
import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx";
import { LocaleToggle } from "@shared/locale/LocaleToggle.tsx";
import { useText } from "@shared/locale/ui-text.ts";
+import { HeritageCardSkeleton } from "@shared/uis/HeritageCardSkeleton.tsx";
+
+const SKELETON_COUNT = 6;
type Props = {
header?: ReactNode;
@@ -20,6 +23,7 @@ type Props = {
errorMessage?: string;
onPageChange?: (page: number) => void;
onBackToAllSites?: () => void;
+ isLoading?: boolean;
};
export default function SearchResultsPage({
@@ -31,6 +35,7 @@ export default function SearchResultsPage({
errorMessage,
onPageChange,
onBackToAllSites,
+ isLoading = false,
}: Props) {
const text = useText();
return (
@@ -86,7 +91,15 @@ export default function SearchResultsPage({
- {items.length === 0 ? (
+ {isLoading ? (
+
+ {Array.from({ length: SKELETON_COUNT }).map((_, i) => (
+ -
+
+
+ ))}
+
+ ) : items.length === 0 ? (
diff --git a/client/src/app/features/search/containers/search-heritage-result-container.tsx b/client/src/app/features/search/containers/search-heritage-result-container.tsx
index 41a5c52..8d8a7fd 100644
--- a/client/src/app/features/search/containers/search-heritage-result-container.tsx
+++ b/client/src/app/features/search/containers/search-heritage-result-container.tsx
@@ -201,7 +201,7 @@ export function SearchHeritageResultsContainer(): React.ReactElement {
};
if (isLoading) {
- return
;
+ return
;
}
if (error) {
diff --git a/client/src/app/features/search/hooks/use-search-heritage-query.ts b/client/src/app/features/search/hooks/use-search-heritage-query.ts
index d9d1b28..5354bc9 100644
--- a/client/src/app/features/search/hooks/use-search-heritage-query.ts
+++ b/client/src/app/features/search/hooks/use-search-heritage-query.ts
@@ -3,6 +3,9 @@ import type { HeritageSearchParams } from "../../../../domain/types.ts";
import { fetchSearchHeritagesResult } from "../apis";
import type { SearchParams } from "../apis/search-api";
import type { ApiWorldHeritageDto, ListResult } from "../../../../domain/types";
+import { getCached, setCached } from "@shared/cache/api-cache.ts";
+
+const CACHE_MAX_AGE_MS = 60_000;
const isAbortError = (e: unknown): boolean => {
return e instanceof DOMException && e.name === "AbortError";
@@ -44,12 +47,20 @@ export function useHeritageSearchQuery(
}
const abortController = new AbortController();
+ const cacheKey = `search:${JSON.stringify(request)}`;
+ const cached = getCached
>(cacheKey, CACHE_MAX_AGE_MS);
- setLoading(true);
+ if (cached) {
+ setData(cached);
+ setLoading(false);
+ } else {
+ setLoading(true);
+ }
setError(null);
fetchSearchHeritagesResult(request, { signal: abortController.signal })
.then((res) => {
+ setCached(cacheKey, res);
setData(res);
})
.catch((e: unknown) => {
diff --git a/client/src/app/features/top/components/HeritageList.tsx b/client/src/app/features/top/components/HeritageList.tsx
index 155d2e2..0b09029 100644
--- a/client/src/app/features/top/components/HeritageList.tsx
+++ b/client/src/app/features/top/components/HeritageList.tsx
@@ -1,13 +1,30 @@
import type { WorldHeritageVm } from "../../../../domain/types.ts";
import { HeritageCard } from "../cards/HeritageCard";
+import { HeritageCardSkeleton } from "@shared/uis/HeritageCardSkeleton.tsx";
+
+const SKELETON_COUNT = 6;
export function HeritageList({
items,
onClickItem,
+ isLoading = false,
}: {
items: ReadonlyArray;
onClickItem?: (id: number) => void;
+ isLoading?: boolean;
}) {
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: SKELETON_COUNT }).map((_, i) => (
+ -
+
+
+ ))}
+
+ );
+ }
+
if (items.length === 0) {
return (
diff --git a/client/src/app/features/top/components/TopPage.tsx b/client/src/app/features/top/components/TopPage.tsx
index d75089a..307a309 100644
--- a/client/src/app/features/top/components/TopPage.tsx
+++ b/client/src/app/features/top/components/TopPage.tsx
@@ -1,5 +1,6 @@
-import type { ReactNode } from "react";
-import { Map } from "./Map.tsx";
+import { lazy, Suspense, type ReactNode } from "react";
+
+const Map = lazy(() => import("./Map.tsx").then((m) => ({ default: m.Map })));
export default function TopPage({
hero,
@@ -23,7 +24,9 @@ export default function TopPage({
{header}
-
+ }>
+
+
diff --git a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx
index 84110dc..4c37b90 100644
--- a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx
+++ b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, lazy, Suspense } from "react";
import { useNavigate } from "react-router-dom";
import type { WorldHeritageDetailVm, SearchValues } from "../../../../../domain/types.ts";
import { HeritageSubHeader } from "../HeritageSubHeader.tsx";
@@ -6,7 +6,11 @@ import { HeritageHero } from "./HeritageHero";
import { HeritageOverViewSection } from "./HeritageOverviewSection";
import { HeritageSidebar } from "./HeritageSidebar";
import { HeritageGallery } from "./HeritageGallery";
-import { DetailHeritageMap } from "@features/top/components/heritage-detail/DetailHeritageMap.tsx";
+const DetailHeritageMap = lazy(() =>
+ import("@features/top/components/heritage-detail/DetailHeritageMap.tsx").then((m) => ({
+ default: m.DetailHeritageMap,
+ })),
+);
import { textType } from "@shared/styles/typography";
import { useSetBreadcrumbLabel } from "@features/breadcrumbs/BreadCrumbHooks.ts";
import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx";
@@ -181,7 +185,9 @@ export function HeritageDetailLayout({ item }: { item: WorldHeritageDetailVm })
{/* Map: mobile only (PC shows in sidebar) */}
-
+ }>
+
+
diff --git a/client/src/app/features/top/containers/top-page-container.tsx b/client/src/app/features/top/containers/top-page-container.tsx
index 128d33b..210a919 100644
--- a/client/src/app/features/top/containers/top-page-container.tsx
+++ b/client/src/app/features/top/containers/top-page-container.tsx
@@ -89,17 +89,6 @@ export default function TopPageContainer(): React.ReactElement {
const header = ;
- if (isLoading) {
- return (
- <>
- {header}
-
- Loading…
-
- >
- );
- }
-
if (isError) {
return (
<>
@@ -121,7 +110,7 @@ export default function TopPageContainer(): React.ReactElement {
}
header={header}
- content={}
+ content={}
pagination={
({
- ...prev,
- loading: true,
- error: null,
- }));
+ const cacheKey = `top:${targetPage}:${targetPerPage}:${targetOrder}:${locale}`;
+ const cached = getCached<{ vmList: WorldHeritageVm[]; pagination: Pagination }>(
+ cacheKey,
+ CACHE_MAX_AGE_MS,
+ );
+
+ if (cached) {
+ setState({
+ data: cached.vmList,
+ pagination: cached.pagination,
+ loading: false,
+ error: null,
+ });
+ } else {
+ setState((prev) => ({ ...prev, loading: true, error: null }));
+ }
fetchTopPage({
currentPage: targetPage,
@@ -84,13 +98,9 @@ export function useTopPage(args: { currentPage: number; perPage: number; order?:
if (abortController.signal.aborted) return;
const vmList = toWorldHeritageListVm(res.items, locale);
+ setCached(cacheKey, { vmList, pagination: res.pagination });
- setState({
- data: vmList,
- pagination: res.pagination,
- loading: false,
- error: null,
- });
+ setState({ data: vmList, pagination: res.pagination, loading: false, error: null });
})
.catch((e: unknown) => {
if (!mountedRef.current) return;
@@ -104,11 +114,7 @@ export function useTopPage(args: { currentPage: number; perPage: number; order?:
return;
}
- setState((prev) => ({
- ...prev,
- loading: false,
- error: e,
- }));
+ setState((prev) => ({ ...prev, loading: false, error: e }));
});
},
[locale],
diff --git a/client/src/shared/cache/api-cache.ts b/client/src/shared/cache/api-cache.ts
new file mode 100644
index 0000000..5d6a64c
--- /dev/null
+++ b/client/src/shared/cache/api-cache.ts
@@ -0,0 +1,13 @@
+type Entry = { data: T; at: number };
+
+const store = new Map>();
+
+export function getCached(key: string, maxAgeMs: number): T | null {
+ const entry = store.get(key) as Entry | undefined;
+ if (!entry || Date.now() - entry.at > maxAgeMs) return null;
+ return entry.data;
+}
+
+export function setCached(key: string, data: T): void {
+ store.set(key, { data, at: Date.now() });
+}
diff --git a/client/src/shared/uis/HeritageCardSkeleton.tsx b/client/src/shared/uis/HeritageCardSkeleton.tsx
new file mode 100644
index 0000000..87215bf
--- /dev/null
+++ b/client/src/shared/uis/HeritageCardSkeleton.tsx
@@ -0,0 +1,5 @@
+export function HeritageCardSkeleton() {
+ return (
+
+ );
+}