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 ? ( + + ) : items.length === 0 ? (

{text.noSitesFound}

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 ( +
+ ); +}