diff --git a/client/src/app/features/breadcrumbs/BreadCrumbHooks.ts b/client/src/app/features/breadcrumbs/BreadCrumbHooks.ts index 7a6bb25..068b4b6 100644 --- a/client/src/app/features/breadcrumbs/BreadCrumbHooks.ts +++ b/client/src/app/features/breadcrumbs/BreadCrumbHooks.ts @@ -1,14 +1,14 @@ import { useContext } from "react"; import BreadcrumbContext from "./BreadCrumbProvider.tsx"; -// label を登録する +// register labeling export const useSetBreadcrumbLabel = () => { const context = useContext(BreadcrumbContext); if (!context) throw new Error("useSetBreadcrumbLabel must be used within BreadcrumbProvider"); return context.setLabel; }; -// 全ラベルを取得する +// fetching all labels export const useBreadcrumbLabels = () => { const context = useContext(BreadcrumbContext); if (!context) throw new Error("useBreadcrumbLabels must be used within BreadcrumbProvider"); diff --git a/client/src/app/features/heritages/mappers/to-world-heritage-detail-vm.ts b/client/src/app/features/heritages/mappers/to-world-heritage-detail-vm.ts index 8e126e8..3079aea 100644 --- a/client/src/app/features/heritages/mappers/to-world-heritage-detail-vm.ts +++ b/client/src/app/features/heritages/mappers/to-world-heritage-detail-vm.ts @@ -25,7 +25,6 @@ export function toWorldHeritageDetailVm(dto: ApiWorldHeritageDetailDto): WorldHe area_hectares: dto.area_hectares, buffer_zone_hectares: dto.buffer_zone_hectares, short_description: dto.short_description, - short_description_jp: dto.short_description_jp, unesco_site_url: dto.unesco_site_url, state_party: dto.state_party, state_party_codes: dto.state_party_codes, @@ -33,6 +32,8 @@ export function toWorldHeritageDetailVm(dto: ApiWorldHeritageDetailDto): WorldHe thumbnail: dto.thumbnail_url, } satisfies import("../../../../domain/types.ts").ApiWorldHeritageDto; + // Ensure the reshaped object satisfies ApiWorldHeritageDto at compile time + const base: WorldHeritageVm = toWorldHeritageVm(listDto); const images: WorldHeritageImageVm[] = dto.images diff --git a/client/src/app/features/map/hooks/region-count.ts b/client/src/app/features/map/hooks/region-count.ts index b30b5d0..1cbc1ef 100644 --- a/client/src/app/features/map/hooks/region-count.ts +++ b/client/src/app/features/map/hooks/region-count.ts @@ -5,7 +5,7 @@ import type { RegionCount } from "../../../../domain/types.ts"; type State = { data: RegionCount[]; isLoading: boolean; - error: Error | null; + error: unknown; }; export function useRegionCount() { diff --git a/client/src/app/features/search/components/SearchResultMapComponent.tsx b/client/src/app/features/search/components/SearchResultMapComponent.tsx index 32b289f..74b00e5 100644 --- a/client/src/app/features/search/components/SearchResultMapComponent.tsx +++ b/client/src/app/features/search/components/SearchResultMapComponent.tsx @@ -5,10 +5,6 @@ import "leaflet/dist/leaflet.css"; import { useEffect } from "react"; import type { LatLngBoundsExpression } from "leaflet"; -type Props = { - items: WorldHeritageVm[]; -}; - const CATEGORY_COLOR: Record = { Cultural: "#f59e0b", Natural: "#22c55e", @@ -37,7 +33,7 @@ function FitBounds({ items }: { items: WorldHeritageVm[] }) { return null; } -export function SearchResultMapComponent({ items }: Props) { +export function SearchResultMapComponent({ items }: { items: WorldHeritageVm[] }) { const navigate = useNavigate(); const validItems = items.filter((item) => isValidCoordinate(item.latitude, item.longitude)); diff --git a/client/src/app/features/search/components/SearchResultsPage.tsx b/client/src/app/features/search/components/SearchResultsPage.tsx index 19964f2..651235f 100644 --- a/client/src/app/features/search/components/SearchResultsPage.tsx +++ b/client/src/app/features/search/components/SearchResultsPage.tsx @@ -1,17 +1,13 @@ import type { ReactNode } from "react"; -import type { WorldHeritageVm } from "../../../../domain/types"; +import type { + WorldHeritageVm, + Pagination as SearchResultsPagination, +} from "../../../../domain/types"; import { HeritageCard } from "@features/top/cards/HeritageCard"; import { Pagination } from "@features/top/components/Pagination.tsx"; import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx"; import { SearchResultMapComponent } from "@features/search/components/SearchResultMapComponent.tsx"; -type SearchResultsPagination = { - current_page: number; - per_page: number; - total: number; - last_page: number; -}; - export type SearchResultsPageProps = { header?: ReactNode; items: WorldHeritageVm[]; diff --git a/client/src/app/features/search/containers/search-heritage-form-container.tsx b/client/src/app/features/search/containers/search-heritage-form-container.tsx index 3b1d2b7..9ea2740 100644 --- a/client/src/app/features/search/containers/search-heritage-form-container.tsx +++ b/client/src/app/features/search/containers/search-heritage-form-container.tsx @@ -1,12 +1,26 @@ -import { useCallback, useMemo, useState, useEffect } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import type { HeritageSearchParams } from "../../../../domain/types"; +import type { + Category, + HeritageSearchParams, + IdSortOption, + SearchValues, + StudyRegion, +} from "../../../../domain/types"; import { parseHeritageSearchParams, serializeHeritageSearchParams, -} from "../mapper/search-heritages.params.ts"; -import { HeritageSubHeader } from "@features/top/components/HeritageSubHeader.tsx"; -import type { SearchValues } from "@features/top/components/HeritageSearchForm.tsx"; +} from "../mapper/search-heritages.params"; +import { DEFAULT_HERITAGE_SEARCH_PARAMS as SEARCH_PARAMS } from "../mapper/search-heritage.types"; +import { HeritageSubHeader } from "@features/top/components/HeritageSubHeader"; + +const DEFAULT_TOP_PER_PAGE = 30; +const DEFAULT_ORDER: IdSortOption = "asc"; + +const toStudyRegionOrNull = (value: StudyRegion | ""): StudyRegion | null => + value === "" ? null : value; + +const toCategoryOrNull = (value: Category | ""): Category | null => (value === "" ? null : value); const toSearchYearOrNull = (value: string): number | null => { const trimmed = value.trim(); @@ -16,71 +30,40 @@ const toSearchYearOrNull = (value: string): number | null => { return Math.floor(parsed); }; -/** Determine whether any valid search condition exists */ -const hasSearchParams = (params: HeritageSearchParams): boolean => - params.search_query !== null || - params.region !== null || - params.category !== null || - params.year_inscribed_from !== null || - params.year_inscribed_to !== null; +const toSearchValues = (params: HeritageSearchParams): SearchValues => ({ + region: params.region ?? "", + category: params.category ?? "", + keyword: params.search_query ?? "", + yearInscribedFrom: params.year_inscribed_from !== null ? String(params.year_inscribed_from) : "", + yearInscribedTo: params.year_inscribed_to !== null ? String(params.year_inscribed_to) : "", +}); -type Props = { - /** Notify the parent which API should be used: list or search */ - onApiModeChange?: (isSearch: boolean) => void; -}; - -export function SearchHeritageFormContainer({ onApiModeChange }: Props) { +export function SearchHeritageFormContainer() { const location = useLocation(); const navigate = useNavigate(); - const params: HeritageSearchParams = useMemo( - () => parseHeritageSearchParams(location.search), - [location.search], - ); - - // If any search parameter exists, we consider it as "search mode". Otherwise, it's "list mode". - const isSearchMode = useMemo(() => hasSearchParams(params), [params]); - - useEffect(() => { - onApiModeChange?.(isSearchMode); - }, [isSearchMode, onApiModeChange]); - - // If no search condition exists on the results page, redirect to the list page. - useEffect(() => { - if (!isSearchMode && location.pathname === "/heritages/results") { - navigate({ pathname: "/heritages", search: location.search }, { replace: true }); - } - }, [isSearchMode, location.pathname, location.search, navigate]); - - const valueFromUrl: SearchValues = useMemo( - () => ({ - region: params.region ?? "", - category: params.category ?? "", - keyword: params.search_query ?? "", - yearInscribedFrom: - params.year_inscribed_from !== null ? String(params.year_inscribed_from) : "", - yearInscribedTo: params.year_inscribed_to !== null ? String(params.year_inscribed_to) : "", - }), - [ - params.region, - params.category, - params.search_query, - params.year_inscribed_from, - params.year_inscribed_to, - ], - ); + const params: HeritageSearchParams = useMemo(() => { + const parsed = parseHeritageSearchParams(location.search); + return { + ...SEARCH_PARAMS, + ...parsed, + current_page: parsed.current_page ?? 1, + per_page: parsed.per_page ?? DEFAULT_TOP_PER_PAGE, + order: parsed.order ?? DEFAULT_ORDER, + }; + }, [location.search]); - const [draft, setDraft] = useState(valueFromUrl); + const [draft, setDraft] = useState(() => toSearchValues(params)); useEffect(() => { - setDraft(valueFromUrl); - }, [valueFromUrl]); + setDraft(toSearchValues(params)); + }, [params]); - const onChange = useCallback((next: SearchValues) => { + const handleChange = useCallback((next: SearchValues) => { setDraft(next); }, []); - const onSubmit = useCallback( + const handleSubmit = useCallback( (query: Partial) => { const merged: SearchValues = { region: query.region ?? draft.region, @@ -91,20 +74,24 @@ export function SearchHeritageFormContainer({ onApiModeChange }: Props) { }; const nextParams: HeritageSearchParams = { - ...params, - region: (merged.region.trim() || null) as HeritageSearchParams["region"], - category: (merged.category.trim() || null) as HeritageSearchParams["category"], - search_query: merged.keyword.trim() || null, + ...SEARCH_PARAMS, + search_query: merged.keyword.trim() === "" ? null : merged.keyword.trim(), + region: toStudyRegionOrNull(merged.region), + category: toCategoryOrNull(merged.category), year_inscribed_from: toSearchYearOrNull(merged.yearInscribedFrom), year_inscribed_to: toSearchYearOrNull(merged.yearInscribedTo), current_page: 1, + per_page: params.per_page ?? DEFAULT_TOP_PER_PAGE, + order: params.order ?? DEFAULT_ORDER, + country: null, }; const search = serializeHeritageSearchParams(nextParams); - navigate({ pathname: location.pathname, search }, { replace: false }); + navigate({ pathname: "/heritages/results", search }, { replace: false }); + setDraft(merged); }, - [navigate, location.pathname, params, draft], + [draft, navigate, params.per_page, params.order], ); - return ; + return ; } 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 fd1c1d6..754defe 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 @@ -9,13 +9,15 @@ import { import { useHeritageSearchQuery } from "../../search/hooks/use-search-heritage-query"; import SearchResultsPage from "../components/SearchResultsPage"; -import type { WorldHeritageVm } from "../../../../domain/types"; +import type { + ApiWorldHeritageDto, + Pagination, + SearchValues, + WorldHeritageVm, +} from "../../../../domain/types"; import { toWorldHeritageListVm } from "@features/heritages/mappers/to-world-heritage-vm"; -import type { Pagination } from "../types"; import { HeritageSubHeader } from "@features/top/components/HeritageSubHeader"; -import type { SearchValues } from "@features/top/components/HeritageSearchForm"; import { DEFAULT_HERITAGE_SEARCH_PARAMS as SEARCH_PARAMS } from "../mapper/search-heritage.types"; -import type { ApiSearchResponse } from "@features/search/apis/search-api"; const fmtRangeText = (pagination: Pagination, count: number): string => { if (count === 0) { @@ -33,7 +35,7 @@ const isObject = (value: unknown): value is Record => const isValidListResult = ( value: unknown, -): value is { items: ApiSearchResponse[]; pagination: Pagination } => { +): value is { items: ApiWorldHeritageDto[]; pagination: Pagination } => { if (!isObject(value)) { return false; } 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 46f6a3c..ed96cde 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 @@ -19,7 +19,7 @@ const toSearchParams = (params: HeritageSearchParams): SearchParams => ({ }); type Options = { - /** If false, the API call is skipped. Defaults to true. */ + /** If false, the API call is skipped. Default is true. */ enabled?: boolean; }; diff --git a/client/src/app/features/search/mapper/search-heritages.params.ts b/client/src/app/features/search/mapper/search-heritages.params.ts index 643f7f7..8ce00d1 100644 --- a/client/src/app/features/search/mapper/search-heritages.params.ts +++ b/client/src/app/features/search/mapper/search-heritages.params.ts @@ -7,58 +7,55 @@ import type { import { CATEGORIES, STUDY_REGIONS } from "../../../../domain/types.ts"; import { DEFAULT_HERITAGE_SEARCH_PARAMS as defaultSearchParams } from "./search-heritage.types.ts"; -const toNullIfEmpty = (v: string | null): string | null => { - if (v == null) return null; - const s = v.trim(); - return s === "" ? null : s; +const toNullIfEmpty = (value: string | null): string | null => { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; }; -const toIntOrNull = (v: string | null): number | null => { - if (v == null) return null; - const s = v.trim(); - if (s === "") return null; +const toIntOrNull = (value: string | null): number | null => { + if (value == null) return null; + const trimmed = value.trim(); + if (trimmed === "") return null; - const n = Number(s); - if (!Number.isFinite(n)) return null; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return null; - return Math.floor(n); + return Math.floor(parsed); }; -const clampMin = (n: number, min: number): number => { - return n < min ? min : n; +const clampMin = (valueNumber: number, min: number): number => { + return valueNumber < min ? min : valueNumber; }; const isStudyRegion = (value: string): value is StudyRegion => { - return STUDY_REGIONS.includes(value as StudyRegion); + return (STUDY_REGIONS as readonly string[]).includes(value); }; -const toRegionOrNull = (v: string | null): StudyRegion | null => { - const s = toNullIfEmpty(v); - if (s == null) return null; - - return isStudyRegion(s) ? s : null; +const toRegionOrNull = (value: string | null): StudyRegion | null => { + const trimmed = toNullIfEmpty(value); + if (trimmed == null) return null; + return isStudyRegion(trimmed) ? trimmed : null; }; const isCategory = (value: string): value is Category => { - return CATEGORIES.includes(value as Category); + return (CATEGORIES as readonly string[]).includes(value); }; -const toCategoryOrNull = (v: string | null): Category | null => { - const s = toNullIfEmpty(v); - if (s == null) return null; - - return isCategory(s) ? s : null; +const toCategoryOrNull = (value: string | null): Category | null => { + const trimmed = toNullIfEmpty(value); + if (trimmed == null) return null; + return isCategory(trimmed) ? trimmed : null; }; const isIdSortOption = (value: string): value is IdSortOption => { return value === "asc" || value === "desc"; }; -const toOrderOrNull = (v: string | null): IdSortOption | null => { - const s = toNullIfEmpty(v); - if (s == null) return null; - - return isIdSortOption(s) ? s : null; +const toOrderOrNull = (value: string | null): IdSortOption | null => { + const trimmed = toNullIfEmpty(value); + if (trimmed == null) return null; + return isIdSortOption(trimmed) ? trimmed : null; }; export function parseHeritageSearchParams(search: string): HeritageSearchParams { @@ -104,43 +101,43 @@ export function parseHeritageSearchParams(search: string): HeritageSearchParams }; } -export function serializeHeritageSearchParams(p: HeritageSearchParams): string { +export function serializeHeritageSearchParams(params: HeritageSearchParams): string { const searchParams = new URLSearchParams(); - const setStr = (k: string, v: string | null, def: string | null) => { - if (v == null) return; - - const s = v.trim(); - if (s === "") return; - if (def != null && s === def) return; - - searchParams.set(k, s); + const setStr = (key: string, value: string | null, defaultValue: string | null) => { + if (value == null) return; + const trimmed = value.trim(); + if (trimmed === "") return; + if (defaultValue != null && trimmed === defaultValue) return; + searchParams.set(key, trimmed); }; - const setNum = (k: string, v: number | null, def: number | null) => { - if (v == null) return; - if (!Number.isFinite(v)) return; - - const i = Math.floor(v); - if (def != null && i === def) return; - - searchParams.set(k, String(i)); + const setNum = (key: string, value: number | null, defaultValue: number | null) => { + if (value == null) return; + if (!Number.isFinite(value)) return; + const floored = Math.floor(value); + if (defaultValue != null && floored === defaultValue) return; + searchParams.set(key, String(floored)); }; - setStr("search_query", p.search_query, defaultSearchParams.search_query); - setStr("country", p.country, defaultSearchParams.country); - setStr("region", p.region, defaultSearchParams.region); - setStr("category", p.category, defaultSearchParams.category); + setStr("search_query", params.search_query, defaultSearchParams.search_query); + setStr("country", params.country, defaultSearchParams.country); + setStr("region", params.region, defaultSearchParams.region); + setStr("category", params.category, defaultSearchParams.category); - setNum("year_inscribed_from", p.year_inscribed_from, defaultSearchParams.year_inscribed_from); - setNum("year_inscribed_to", p.year_inscribed_to, defaultSearchParams.year_inscribed_to); - setNum("current_page", p.current_page, defaultSearchParams.current_page); - setNum("per_page", p.per_page, defaultSearchParams.per_page); + setNum( + "year_inscribed_from", + params.year_inscribed_from, + defaultSearchParams.year_inscribed_from, + ); + setNum("year_inscribed_to", params.year_inscribed_to, defaultSearchParams.year_inscribed_to); + setNum("current_page", params.current_page, defaultSearchParams.current_page); + setNum("per_page", params.per_page, defaultSearchParams.per_page); - if (p.order != null && p.order !== defaultSearchParams.order) { - searchParams.set("order", p.order); + if (params.order != null && params.order !== defaultSearchParams.order) { + searchParams.set("order", params.order); } - const qs = searchParams.toString(); - return qs ? `?${qs}` : ""; + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ""; } diff --git a/client/src/app/features/search/mapper/to-search-heritage-vm.ts b/client/src/app/features/search/mapper/to-search-heritage-vm.ts index b9a558b..174f570 100644 --- a/client/src/app/features/search/mapper/to-search-heritage-vm.ts +++ b/client/src/app/features/search/mapper/to-search-heritage-vm.ts @@ -1,17 +1,10 @@ -import type { ApiWorldHeritageDto, WorldHeritageVm } from "../../../../domain/types"; +import type { ApiWorldHeritageDto, Pagination, WorldHeritageVm } from "../../../../domain/types"; import type { HeritageSearchResponse } from "../types"; import { toWorldHeritageListVm } from "../../heritages/mappers/to-world-heritage-vm"; -export type UiPagination = { - current_page: number; - per_page: number; - total: number; - last_page: number; -}; - export type HeritageSearchResultVm = { items: WorldHeritageVm[]; - pagination: UiPagination; + pagination: Pagination; isFirstPage: boolean; isLastPage: boolean; rangeText: string; @@ -20,58 +13,66 @@ export type HeritageSearchResultVm = { type FlatSuccess = { status: "success"; data: ApiWorldHeritageDto[] }; type PagedSuccess = { status: "success"; - data: { items: ApiWorldHeritageDto[]; pagination: UiPagination }; + data: { items: ApiWorldHeritageDto[]; pagination: Pagination }; }; -const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null; +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); -const isUiPagination = (v: unknown): v is UiPagination => { - if (!isObject(v)) return false; - const n = (x: unknown) => typeof x === "number" && Number.isFinite(x); - return n(v.current_page) && n(v.per_page) && n(v.total) && n(v.last_page); +const isPagination = (value: unknown): value is Pagination => { + if (!isObject(value)) return false; + return ( + isFiniteNumber(value.current_page) && + isFiniteNumber(value.per_page) && + isFiniteNumber(value.total) && + isFiniteNumber(value.last_page) + ); }; -const isFlatSuccess = (v: unknown): v is FlatSuccess => { - if (!isObject(v)) return false; - return v.status === "success" && Array.isArray(v.data); +const isFlatSuccess = (value: unknown): value is FlatSuccess => { + if (!isObject(value)) return false; + return value.status === "success" && Array.isArray(value.data); }; -const isPagedSuccess = (v: unknown): v is PagedSuccess => { - if (!isObject(v)) return false; - if (v.status !== "success") return false; - const d = v.data; - if (!isObject(d)) return false; - return Array.isArray(d.items) && isUiPagination(d.pagination); +const isPagedSuccess = (value: unknown): value is PagedSuccess => { + if (!isObject(value)) return false; + if (value.status !== "success") return false; + const data = value.data; + if (!isObject(data)) return false; + return Array.isArray(data.items) && isPagination(data.pagination); }; -const fmtRangeText = (p: UiPagination, count: number) => { - if (count === 0) return `0 of ${p.total.toLocaleString("en-CA")}`; - const start = (p.current_page - 1) * p.per_page + 1; +const formatRangeText = (pagination: Pagination, count: number): string => { + if (count === 0) return `0 of ${pagination.total.toLocaleString("en-CA")}`; + const start = (pagination.current_page - 1) * pagination.per_page + 1; const end = start + count - 1; - return `${start}–${end} of ${p.total.toLocaleString("en-CA")}`; + return `${start}–${end} of ${pagination.total.toLocaleString("en-CA")}`; }; export const toHeritageSearchResultVm = ( - res: HeritageSearchResponse | FlatSuccess, + response: HeritageSearchResponse | FlatSuccess, ): HeritageSearchResultVm => { - if (isPagedSuccess(res)) { - const items = toWorldHeritageListVm(res.data.items); - const pagination = res.data.pagination; + if (isPagedSuccess(response)) { + const items = toWorldHeritageListVm(response.data.items); + const pagination = response.data.pagination; return { items, pagination, isFirstPage: pagination.current_page <= 1, isLastPage: pagination.current_page >= pagination.last_page, - rangeText: fmtRangeText(pagination, items.length), + rangeText: formatRangeText(pagination, items.length), }; } - if (isFlatSuccess(res)) { - const items = toWorldHeritageListVm(res.data); - const total = res.data.length; + if (isFlatSuccess(response)) { + const items = toWorldHeritageListVm(response.data); + const total = response.data.length; - const pagination: UiPagination = { + const pagination: Pagination = { current_page: 1, per_page: total, total, @@ -83,7 +84,7 @@ export const toHeritageSearchResultVm = ( pagination, isFirstPage: true, isLastPage: true, - rangeText: fmtRangeText(pagination, items.length), + rangeText: formatRangeText(pagination, items.length), }; } diff --git a/client/src/app/features/search/search.params.ts b/client/src/app/features/search/search.params.ts deleted file mode 100644 index e7a027a..0000000 --- a/client/src/app/features/search/search.params.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type SearchHeritagesApiResponse = { - status: "success" | "error"; - data: { - data: object[]; - pagination: { - current_page: number; - per_page: number; - total: number; - last_page: number; - }; - }; -}; diff --git a/client/src/app/features/search/types.ts b/client/src/app/features/search/types.ts index 18fa781..0d46dec 100644 --- a/client/src/app/features/search/types.ts +++ b/client/src/app/features/search/types.ts @@ -1,24 +1,4 @@ -import type { ApiWorldHeritageDto } from "../../../domain/types.ts"; - -export type HeritageSearchParams = { - search_query: string | null; - country: string | null; - region: string | null; - category: string | null; - year_inscribed_from: string | null; - year_inscribed_to: string | null; - current_page: number; - per_page: number; -}; - -export type HeritageSearchRequest = HeritageSearchParams; - -export type Pagination = { - current_page: number; - per_page: number; - total: number; - last_page: number; -}; +import type { ApiWorldHeritageDto, Pagination } from "../../../domain/types.ts"; export type HeritageSearchResponse = | { @@ -32,11 +12,3 @@ export type HeritageSearchResponse = status: "error"; data: unknown; }; - -export type SearchHeritagesApiResponse = { - status: "success" | "error"; - data: { - data: ApiWorldHeritageDto[]; - pagination: Pagination; - }; -}; diff --git a/client/src/app/features/top/apis/top-api.ts b/client/src/app/features/top/apis/top-api.ts index 4668567..8167fae 100644 --- a/client/src/app/features/top/apis/top-api.ts +++ b/client/src/app/features/top/apis/top-api.ts @@ -10,14 +10,22 @@ export type TopApiDeps = { fetchImpl?: typeof fetch; }; -type DetailResponse = { +type DetailResponse = { status: string; - data: T; + data: ApiWorldHeritageDetailDto; }; -type ListResponse = { +type ListResponse = { status: string; - data: ListResult; + data: ListResult; +}; + +const isListResponse = (json: unknown): json is ListResponse => { + return typeof json === "object" && json !== null && "status" in json && "data" in json; +}; + +const isDetailResponse = (json: unknown): json is DetailResponse => { + return typeof json === "object" && json !== null && "status" in json && "data" in json; }; const normalizeApiBase = (apiBase: string): string => { @@ -56,7 +64,11 @@ export const createTopApi = ({ apiBase, fetchImpl = fetch }: TopApiDeps) => { throw new Error(`HTTP ${res.status}`); } - const json = (await res.json()) as ListResponse; + const json = await res.json(); + + if (!isListResponse(json)) { + throw new Error("Unexpected response shape"); + } if (json.status !== "success") { throw new Error(`API status is not success: ${json.status}`); } @@ -74,7 +86,12 @@ export const createTopApi = ({ apiBase, fetchImpl = fetch }: TopApiDeps) => { throw new Error(`HTTP ${res.status}`); } - const json = (await res.json()) as DetailResponse; + const json = await res.json(); + + if (!isDetailResponse(json)) { + throw new Error("Unexpected response shape"); + } + if (json.status !== "success") { throw new Error(`API status is not success: ${json.status}`); } diff --git a/client/src/app/features/top/cards/HeritageCard.tsx b/client/src/app/features/top/cards/HeritageCard.tsx index 6f8d38f..c1139a8 100644 --- a/client/src/app/features/top/cards/HeritageCard.tsx +++ b/client/src/app/features/top/cards/HeritageCard.tsx @@ -30,15 +30,16 @@ function TagChip({ children }: { children: ReactNode }) { ); } -type HeritageCardProps = { - item: WorldHeritageVm; - onClickItem?: (id: number) => void; -}; - const DESC_CLAMP = 2; const CRITERIA_MAX = 4; -export function HeritageCard({ item, onClickItem }: HeritageCardProps) { +export function HeritageCard({ + item, + onClickItem, +}: { + item: WorldHeritageVm; + onClickItem?: (id: number) => void; +}) { const goDetail = () => { if (!onClickItem) return; onClickItem(item.id); diff --git a/client/src/app/features/top/components/HeritageSearchForm.tsx b/client/src/app/features/top/components/HeritageSearchForm.tsx index b9af290..18e17dd 100644 --- a/client/src/app/features/top/components/HeritageSearchForm.tsx +++ b/client/src/app/features/top/components/HeritageSearchForm.tsx @@ -4,17 +4,10 @@ import { CATEGORIES, STUDY_REGIONS, type Category, + type SearchValues, type StudyRegion, } from "../../../../domain/types.ts"; -export type SearchValues = { - region: StudyRegion | ""; - category: Category | ""; - keyword: string; - yearInscribedFrom: string; - yearInscribedTo: string; -}; - type Props = { value?: SearchValues; onChange?: (next: SearchValues) => void; diff --git a/client/src/app/features/top/components/HeritageSubHeader.tsx b/client/src/app/features/top/components/HeritageSubHeader.tsx index 65e81e9..117f2f8 100644 --- a/client/src/app/features/top/components/HeritageSubHeader.tsx +++ b/client/src/app/features/top/components/HeritageSubHeader.tsx @@ -1,4 +1,5 @@ -import { HeritageSearchForm, type SearchValues } from "./HeritageSearchForm"; +import { HeritageSearchForm } from "./HeritageSearchForm"; +import { type SearchValues } from "../../../../domain/types.ts"; export type Props = { value: SearchValues; diff --git a/client/src/app/features/top/components/Pagination.tsx b/client/src/app/features/top/components/Pagination.tsx index 3f73ee8..7029eef 100644 --- a/client/src/app/features/top/components/Pagination.tsx +++ b/client/src/app/features/top/components/Pagination.tsx @@ -1,15 +1,5 @@ import type { MouseEvent } from "react"; -type PaginationProps = { - currentPage: number; - perPage: number; - lastPage: number; - onChange: (page: number) => void; - disabled?: boolean; - windowSize?: number; - simple?: boolean; -}; - function clamp(n: number, min: number, max: number): number { return Math.max(min, Math.min(max, n)); } @@ -18,19 +8,19 @@ function buildPageItems( current: number, last: number, windowSize: number, - edgeCount: number = 4, //先頭/末尾で必ず出す個数 + edgeCount: number = 4, // display at least count page ): Array { if (last <= 1) return [1]; const pages = new Set(); - // 先頭ページ + // top page for (let p = 1; p <= Math.min(edgeCount, last); p++) pages.add(p); - // 末尾ページ + // last page for (let p = Math.max(1, last - edgeCount + 1); p <= last; p++) pages.add(p); - // 現在ページ + // current page for (let p = current - windowSize; p <= current + windowSize; p++) { if (p >= 1 && p <= last) pages.add(p); } @@ -55,7 +45,15 @@ export function Pagination({ disabled = false, windowSize = 1, simple = false, -}: PaginationProps) { +}: { + currentPage: number; + perPage: number; + lastPage: number; + onChange: (page: number) => void; + disabled?: boolean; + windowSize?: number; + simple?: boolean; +}) { const last = Math.max(1, lastPage); const current = clamp(currentPage, 1, last); diff --git a/client/src/app/features/top/components/TopPage.tsx b/client/src/app/features/top/components/TopPage.tsx index 8add0ff..4b8f5ff 100644 --- a/client/src/app/features/top/components/TopPage.tsx +++ b/client/src/app/features/top/components/TopPage.tsx @@ -1,8 +1,7 @@ -import type { WorldHeritageVm } from "../../../../domain/types.ts"; +import type { WorldHeritageVm, IdSortOption } from "../../../../domain/types.ts"; import { HeritageCard } from "../cards/HeritageCard"; import type { ReactNode } from "react"; import { Pagination } from "@features/top/components/Pagination.tsx"; -import type { IdSortOption } from "../../../../domain/types.ts"; import { Map } from "./Map.tsx"; export type TopPageProps = { diff --git a/client/src/app/features/top/components/heritage-detail/DetailHeritageMap.tsx b/client/src/app/features/top/components/heritage-detail/DetailHeritageMap.tsx index d614034..c800a94 100644 --- a/client/src/app/features/top/components/heritage-detail/DetailHeritageMap.tsx +++ b/client/src/app/features/top/components/heritage-detail/DetailHeritageMap.tsx @@ -3,12 +3,6 @@ import { divIcon } from "leaflet"; import "leaflet/dist/leaflet.css"; import LocationOnIcon from "@mui/icons-material/LocationOn"; -type Props = { - latitude: number | null; - longitude: number | null; - name?: string; -}; - const isValidCoord = (lat: number | null, lng: number | null): lat is number => lat !== null && lng !== null && lat !== 0 && lng !== 0; @@ -19,7 +13,15 @@ const redDiamondIcon = divIcon({ iconAnchor: [10, 10], }); -export function DetailHeritageMap({ latitude, longitude, name }: Props) { +export function DetailHeritageMap({ + latitude, + longitude, + name, +}: { + latitude: number | null; + longitude: number | null; + name?: string; +}) { if (!isValidCoord(latitude, longitude)) { return null; } 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 c794974..8772818 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx @@ -1,9 +1,8 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import type { WorldHeritageDetailVm } from "../../../../../domain/types.ts"; +import type { WorldHeritageDetailVm, SearchValues } from "../../../../../domain/types.ts"; import type { Locale } from "../../../../../domain/criteria"; import { HeritageSubHeader } from "../HeritageSubHeader.tsx"; -import { type SearchValues } from "@features/top/components/HeritageSearchForm.tsx"; import { HeritageHero } from "./HeritageHero"; import { HeritageOverViewSection } from "./HeritageOverviewSection"; import { HeritageSidebar } from "./HeritageSidebar"; @@ -13,12 +12,6 @@ import { textType } from "@shared/styles/typography"; import { useSetBreadcrumbLabel } from "@features/breadcrumbs/BreadCrumbHooks.ts"; import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx"; -type Props = { - item: WorldHeritageDetailVm; - locale: Locale; - toggleLocale: () => void; -}; - const DEFAULT_SEARCH: SearchValues = { region: "", category: "", @@ -27,12 +20,7 @@ const DEFAULT_SEARCH: SearchValues = { yearInscribedTo: "", }; -type TabItem = { - label: string; - href: `#${string}`; -}; - -const TABS: readonly TabItem[] = [ +const TABS: readonly { label: string; href: `#${string}` }[] = [ { label: "Description", href: "#content" }, { label: "Maps", href: "#geo-map" }, { label: "Gallery", href: "#gallery" }, @@ -41,20 +29,27 @@ const TABS: readonly TabItem[] = [ const formatCriteriaInline = (criteria: string[] | undefined) => criteria?.length ? criteria.map((c) => `(${c})`).join("") : "—"; -function HeritageDetailTabs({ items }: { items: readonly TabItem[] }) { +function HeritageDetailTabs({ + items, +}: { + items: readonly { + label: string; + href: `#${string}`; + }[]; +}) { return (
@@ -98,7 +93,15 @@ function KeyExamInfo({ item }: { item: WorldHeritageDetailVm }) { ); } -export function HeritageDetailLayout({ item, locale, toggleLocale }: Props) { +export function HeritageDetailLayout({ + item, + locale, + toggleLocale, +}: { + item: WorldHeritageDetailVm; + locale: Locale; + toggleLocale: () => void; +}) { const [search, setSearch] = useState(DEFAULT_SEARCH); const setLabel = useSetBreadcrumbLabel(); const navigate = useNavigate(); diff --git a/client/src/app/features/top/components/heritage-detail/HeritageGallery.tsx b/client/src/app/features/top/components/heritage-detail/HeritageGallery.tsx index 80b55a7..a443b10 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageGallery.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageGallery.tsx @@ -1,14 +1,17 @@ import type { WorldHeritageImageVm } from "../../../../../domain/types.ts"; import { Button } from "@shared/uis/Button.tsx"; -type Props = { +export function HeritageGallery({ + images, + previewCount = 6, + onOpenGallery, + onSelectImage, +}: { images: WorldHeritageImageVm[]; previewCount?: number; onOpenGallery?: () => void; onSelectImage?: (img: Pick) => void; -}; - -export function HeritageGallery({ images, previewCount = 6, onOpenGallery, onSelectImage }: Props) { +}) { if (!images?.length) return null; const hasMore = images.length > previewCount; diff --git a/client/src/app/features/top/components/heritage-detail/HeritageHero.tsx b/client/src/app/features/top/components/heritage-detail/HeritageHero.tsx index c287805..c1f8528 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageHero.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageHero.tsx @@ -1,12 +1,7 @@ import type { WorldHeritageDetailVm, WorldHeritageImageVm } from "../../../../../domain/types.ts"; import type { Locale } from "../../../../../domain/criteria.ts"; -type Props = { - item: WorldHeritageDetailVm; - locale: Locale; -}; - -export function HeritageHero({ item, locale }: Props) { +export function HeritageHero({ item, locale }: { item: WorldHeritageDetailVm; locale: Locale }) { const primaryImage: WorldHeritageImageVm | undefined = item.images.find((img) => img.isPrimary) ?? item.images[0]; diff --git a/client/src/app/features/top/components/heritage-detail/HeritageMetaChips.tsx b/client/src/app/features/top/components/heritage-detail/HeritageMetaChips.tsx index 9772b73..7a876d2 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageMetaChips.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageMetaChips.tsx @@ -1,9 +1,5 @@ import type { WorldHeritageVm } from "../../../../../domain/types.ts"; -type Props = { - item: WorldHeritageVm; -}; - type ChipProps = { label: string; value?: string | number | null; @@ -29,7 +25,7 @@ function Chip({ label, value, tone = "default" }: ChipProps) { ); } -export function HeritageMetaChips({ item }: Props) { +export function HeritageMetaChips({ item }: { item: WorldHeritageVm }) { const criteriaLabel = Array.isArray(item.criteria) && item.criteria.length > 0 ? item.criteria.join(", ") : undefined; diff --git a/client/src/app/features/top/components/heritage-detail/HeritageMetadataList.tsx b/client/src/app/features/top/components/heritage-detail/HeritageMetadataList.tsx index 5ae844f..7566389 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageMetadataList.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageMetadataList.tsx @@ -7,12 +7,13 @@ export type MetadataItem = { hidden?: boolean; }; -type Props = { - items: readonly MetadataItem[] | null; +export function HeritageMetadataList({ + items, + className, +}: { + items: readonly MetadataItem[]; className?: string; -}; - -export function HeritageMetadataList({ items, className }: Props) { +}) { return (
{items diff --git a/client/src/app/features/top/components/heritage-detail/HeritageOverviewSection.tsx b/client/src/app/features/top/components/heritage-detail/HeritageOverviewSection.tsx index 5d97514..bde618b 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageOverviewSection.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageOverviewSection.tsx @@ -1,12 +1,13 @@ import type { WorldHeritageDetailVm } from "../../../../../domain/types.ts"; import { textType } from "@shared/styles/typography.ts"; -type Props = { +export function HeritageOverViewSection({ + item, + locale, +}: { item: WorldHeritageDetailVm; locale: string; -}; - -export function HeritageOverViewSection({ item, locale }: Props) { +}) { return (
criteria?.length ? criteria.map((c) => `(${c})`).join("") : "—"; @@ -22,7 +18,7 @@ const formatLongitude = (lng: number): string => { return `${Math.abs(lng).toFixed(4)}° ${direction}`; }; -export function HeritageSidebar({ item }: Props) { +export function HeritageSidebar({ item }: { item: WorldHeritageDetailVm }) { const hasCoord = item.latitude != null && item.longitude != null && !isZeroCoord(item.latitude, item.longitude); diff --git a/client/src/app/features/top/components/heritage-detail/HeritageSubHeader.tsx b/client/src/app/features/top/components/heritage-detail/HeritageSubHeader.tsx index 446406a..f855d6d 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageSubHeader.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageSubHeader.tsx @@ -1,9 +1,13 @@ import { useMemo, useState } from "react"; import { Button } from "@shared/uis/Button"; import SearchIcon from "@mui/icons-material/Search"; -import { STUDY_REGIONS, CATEGORIES } from "../../../../../domain/types"; -import type { Category, StudyRegion } from "../../../../../domain/types"; -import type { SearchValues } from "@features/top/components/HeritageSearchForm.tsx"; +import { + type Category, + type StudyRegion, + type SearchValues, + STUDY_REGIONS, + CATEGORIES, +} from "../../../../../domain/types"; type Props = { value?: SearchValues; diff --git a/client/src/app/features/top/components/heritage-detail/HeroImage.tsx b/client/src/app/features/top/components/heritage-detail/HeroImage.tsx index 1c9beb6..0ff7f98 100644 --- a/client/src/app/features/top/components/heritage-detail/HeroImage.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeroImage.tsx @@ -1,13 +1,11 @@ -import React from "react"; +import type { FC } from "react"; -type HeroImageProps = { +export const HeroImage: FC<{ src: string; alt: string; credit?: string | null; unescoUrl?: string | null; -}; - -export const HeroImage: React.FC = ({ src, alt, credit, unescoUrl }) => ( +}> = ({ src, alt, credit, unescoUrl }) => (
{alt} 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 3517520..446bb07 100644 --- a/client/src/app/features/top/containers/top-page-container.tsx +++ b/client/src/app/features/top/containers/top-page-container.tsx @@ -2,49 +2,20 @@ import * as React from "react"; import { useLocation, useNavigate } from "react-router-dom"; import TopPage from "../components/TopPage"; import { useTopPage } from "../hooks/use-top-page"; - -import { HeritageSubHeader } from "../components/HeritageSubHeader"; -import { type SearchValues } from "../components/HeritageSearchForm"; -import type { - Category, - HeritageSearchParams, - IdSortOption, - StudyRegion, -} from "../../../../domain/types"; -import { - parseHeritageSearchParams, - serializeHeritageSearchParams, -} from "@features/search/mapper/search-heritages.params"; +import { SearchHeritageFormContainer } from "@features/search/containers/search-heritage-form-container"; +import type { IdSortOption } from "../../../../domain/types"; +import { parseHeritageSearchParams } from "@features/search/mapper/search-heritages.params"; import { DEFAULT_HERITAGE_SEARCH_PARAMS as SEARCH_PARAMS } from "@features/search/mapper/search-heritage.types"; const DEFAULT_TOP_PER_PAGE = 30; const DEFAULT_ORDER: IdSortOption = "asc"; -const toStudyRegionOrNull = (value: StudyRegion | ""): StudyRegion | null => { - return value === "" ? null : value; -}; - -const toCategoryOrNull = (value: Category | ""): Category | null => { - return value === "" ? null : value; -}; - -const toSearchYearOrNull = (value: string): number | null => { - const trimmed = value.trim(); - if (trimmed === "") return null; - - const parsed = Number(trimmed); - if (!Number.isFinite(parsed)) return null; - - return Math.floor(parsed); -}; - export default function TopPageContainer(): React.ReactElement { const location = useLocation(); const navigate = useNavigate(); - const params: HeritageSearchParams = React.useMemo(() => { + const params = React.useMemo(() => { const parsed = parseHeritageSearchParams(location.search); - return { ...SEARCH_PARAMS, ...parsed, @@ -69,110 +40,54 @@ export default function TopPageContainer(): React.ReactElement { [navigate], ); - const [draft, setDraft] = React.useState({ - region: params.region ?? "", - category: params.category ?? "", - keyword: params.search_query ?? "", - yearInscribedFrom: - params.year_inscribed_from !== null ? String(params.year_inscribed_from) : "", - yearInscribedTo: params.year_inscribed_to !== null ? String(params.year_inscribed_to) : "", - }); - - React.useEffect(() => { - setDraft({ - region: params.region ?? "", - category: params.category ?? "", - keyword: params.search_query ?? "", - yearInscribedFrom: - params.year_inscribed_from !== null ? String(params.year_inscribed_from) : "", - yearInscribedTo: params.year_inscribed_to !== null ? String(params.year_inscribed_to) : "", - }); - }, [ - params.region, - params.category, - params.search_query, - params.year_inscribed_from, - params.year_inscribed_to, - ]); - - const handleSubmit = React.useCallback( - (query: Partial) => { - const merged: SearchValues = { - region: query.region ?? draft.region, - category: query.category ?? draft.category, - keyword: query.keyword ?? draft.keyword, - yearInscribedFrom: query.yearInscribedFrom ?? draft.yearInscribedFrom, - yearInscribedTo: query.yearInscribedTo ?? draft.yearInscribedTo, - }; - - const nextParams: HeritageSearchParams = { - ...SEARCH_PARAMS, - search_query: merged.keyword.trim() === "" ? null : merged.keyword.trim(), - region: toStudyRegionOrNull(merged.region), - category: toCategoryOrNull(merged.category), - year_inscribed_from: toSearchYearOrNull(merged.yearInscribedFrom), - year_inscribed_to: toSearchYearOrNull(merged.yearInscribedTo), - current_page: 1, - per_page: perPage, - order, - country: null, - }; - - const search = serializeHeritageSearchParams(nextParams); - - navigate({ pathname: "/heritages/results", search }, { replace: false }); - setDraft(merged); - }, - [draft, navigate, order, perPage], - ); - - const handleChangeDraft = React.useCallback((value: SearchValues) => { - setDraft(value); - }, []); - const handleChangePage = React.useCallback( (page: number) => { - const sp = new URLSearchParams(location.search); - - sp.set("current_page", String(page)); - sp.set("per_page", String(perPage)); - sp.set("order", order); - - navigate({ pathname: "/heritages", search: `?${sp.toString()}` }, { replace: false }); + const searchParams = new URLSearchParams(location.search); + searchParams.set("current_page", String(page)); + searchParams.set("per_page", String(perPage)); + searchParams.set("order", order); + navigate( + { pathname: "/heritages", search: `?${searchParams.toString()}` }, + { replace: false }, + ); }, [location.search, navigate, order, perPage], ); const handleChangePerPage = React.useCallback( (nextPerPage: number) => { - const sp = new URLSearchParams(location.search); - - sp.set("current_page", "1"); - sp.set("per_page", String(nextPerPage)); - sp.set("order", order); - - navigate({ pathname: "/heritages", search: `?${sp.toString()}` }, { replace: false }); + const searchParams = new URLSearchParams(location.search); + searchParams.set("current_page", "1"); + searchParams.set("per_page", String(nextPerPage)); + searchParams.set("order", order); + navigate( + { pathname: "/heritages", search: `?${searchParams.toString()}` }, + { replace: false }, + ); }, [location.search, navigate, order], ); const handleChangeOrder = React.useCallback( (nextOrder: IdSortOption) => { - const sp = new URLSearchParams(location.search); - - sp.set("current_page", "1"); - sp.set("per_page", String(perPage)); - sp.set("order", nextOrder); - - navigate({ pathname: "/heritages", search: `?${sp.toString()}` }, { replace: false }); + const searchParams = new URLSearchParams(location.search); + searchParams.set("current_page", "1"); + searchParams.set("per_page", String(perPage)); + searchParams.set("order", nextOrder); + navigate( + { pathname: "/heritages", search: `?${searchParams.toString()}` }, + { replace: false }, + ); }, [location.search, navigate, perPage], ); + const header = ; + if (isLoading) { return ( <> - + {header}
Loading…
@@ -183,7 +98,7 @@ export default function TopPageContainer(): React.ReactElement { if (isError) { return ( <> - + {header}
Failed to load.