diff --git a/package.json b/package.json index f520961..a129786 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "format": "biome format --write" }, "dependencies": { - "@base-ui/react": "^1.1.0", + "@base-ui/react": "^1.6.0", "@icons-pack/react-simple-icons": "^13.13.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", @@ -22,7 +22,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.545.0", - "next": "16.0.10", + "next": "16.3.3", "radix-ui": "^1.4.3", "react": "19.2.0", "react-dom": "19.2.0", @@ -31,7 +31,7 @@ }, "devDependencies": { "@biomejs/biome": "2.2.0", - "@tailwindcss/postcss": "^4", + "@tailwindcss/postcss": "^4.3.3", "@types/node": "^20", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", diff --git a/src/app/Header.tsx b/src/app/Header.tsx index d86e6ca..9cf76f0 100644 --- a/src/app/Header.tsx +++ b/src/app/Header.tsx @@ -1,16 +1,22 @@ -import Image from "next/image"; -import Link from "next/link"; -import { H1 } from "@/components/datacite/Headings"; -import logo from "./DataCite-Logo.png"; +"use client"; + +import { Suspense } from "react"; +import GlobalSearch from "@/components/GlobalSearch"; export default function Header() { + return ( -
-

+
+ {/*

DataCite logo -

+

*/} +
+ }> + + +
); } diff --git a/src/app/[id]/page.tsx b/src/app/[id]/page.tsx index c75bf6d..6b9c73b 100644 --- a/src/app/[id]/page.tsx +++ b/src/app/[id]/page.tsx @@ -1,8 +1,4 @@ import { redirect } from "next/navigation"; -import * as Cards from "@/components/cards/Cards"; -import OverviewCard from "@/components/cards/OverviewCard"; -import { SectionHeader } from "@/components/datacite/Headings"; -import { fetchEntity } from "@/data/fetch"; export default async function Page({ params, @@ -11,50 +7,16 @@ export default async function Page({ const { id } = await params; // Redirect to lowercased id if it contains uppercase letters - if (id !== id.toLowerCase()) { - const urlSearchParams = new URLSearchParams(); - Object.entries(await searchParams).forEach(([key, value]) => { - if (!value) return; + const urlSearchParams = new URLSearchParams(); + Object.entries(await searchParams).forEach(([key, value]) => { + if (!value) return; - if (Array.isArray(value)) - for (const v of value) urlSearchParams.append(key, v); - else urlSearchParams.append(key, value); - }); + if (Array.isArray(value)) + for (const v of value) urlSearchParams.append(key, v); + else urlSearchParams.append(key, value); + }); - redirect(`/${id.toLowerCase()}?${urlSearchParams.toString()}`); - } + urlSearchParams.append('tab', 'metadata-dashboard') - const entity = await fetchEntity(id); - if (!entity) return null; - - return ( -
- - - - Connections to People, Organizations, and Related Resources - - - - - - - - Descriptive Metadata - - - - - - - - - - - - - - -
- ); + redirect(`/org-repo/${id.toLowerCase()}?${urlSearchParams.toString()}`); } diff --git a/src/app/dois/DoiFacetsPanel.tsx b/src/app/dois/DoiFacetsPanel.tsx new file mode 100644 index 0000000..72f6543 --- /dev/null +++ b/src/app/dois/DoiFacetsPanel.tsx @@ -0,0 +1,168 @@ +"use client"; + +import DoiRegistrationsChart from "@/components/DoiRegistrationsChart"; +import ResourceTypesChart from "@/components/ResourceTypesChart"; +import { H3 } from "@/components/datacite/Headings"; +import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Spinner } from "@/components/ui/spinner"; +import { DOI_FACET_CONFIGS } from "@/app/dois/doiConfig"; +import { asNumber } from "@/util"; +import type { DoiFacetConfig, DoiFacetValue } from "@/types"; + +type Props = { + accordionOpenKeys: string[]; + selectedFacetValues: Record; + accordionFacetValues: Record; + accordionFacetLoading: Record; + interactingFacetKey: string | null; + facetsLoading: boolean; + publicationYearData: Array<{ year: string; count: number }>; + resourceTypeFacetData: Array<{ type: string; count: number }>; + selectedPublicationYears: Set; + selectedResourceTypes: Set; + onOpenChange: (openKeys: string[]) => void; + onFacetToggle: (facetKey: string, item: DoiFacetValue, checked: boolean) => void; + onPublicationYearClick: (year: string) => void; + onResourceTypeClick: (resourceType: string) => void; +}; + +function getCompareValue(config: DoiFacetConfig, value: DoiFacetValue) { + return config.valueField === "title" ? value.title : value.id; +} + +export default function DoiFacetsPanel(props: Props) { + return ( + + ); +} \ No newline at end of file diff --git a/src/app/dois/DoiMetricsSummary.tsx b/src/app/dois/DoiMetricsSummary.tsx new file mode 100644 index 0000000..64732e3 --- /dev/null +++ b/src/app/dois/DoiMetricsSummary.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { ArrowDownToLine, Eye, Quote } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { dmSans } from "@/lib/fonts"; +import { asNumber } from "@/util"; +import type { DoiMetricState } from "@/types"; + +type Props = { + total: number; + isLoadingRecords: boolean; + citationCount: DoiMetricState; + viewCount: DoiMetricState; + downloadCount: DoiMetricState; +}; + +function MetricValue(props: { metric: DoiMetricState }) { + if (props.metric.isLoading) { + return ; + } + + return <>{asNumber(props.metric.value || 0)}; +} + +export default function DoiMetricsSummary(props: Props) { + return ( +
+
+ {props.isLoadingRecords ? ( + + ) : ( +

+ {asNumber(props.total)} results +

+ )} +
+
+ + + + + + + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/dois/DoiResultsPanel.tsx b/src/app/dois/DoiResultsPanel.tsx new file mode 100644 index 0000000..5e7bcaa --- /dev/null +++ b/src/app/dois/DoiResultsPanel.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { Popover } from "@base-ui/react/popover"; +import { CircleHelp, SquareArrowOutUpRight } from "lucide-react"; +import { useMemo } from "react"; +import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue } from "@/components/ui/combobox"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Toolbar, ToolbarGroup } from "@/components/ui/toolbar"; +import { DoiRecordList } from "@/components/DoiRecordList"; +import { DoiRecordListSkeleton } from "@/components/DoiRecordListSkeleton"; +import DoiExportMenu from "@/components/DoiExportMenu"; +import DoiMetricsSummary from "@/app/dois/DoiMetricsSummary"; +import { DOI_SORT_OPTIONS } from "@/app/dois/doiConfig"; +import { buildDoiRecordListItem } from "@/lib/resultItems"; +import type { DoiMetricState, DoiRecord, SelectOption } from "@/types"; + +type Props = { + searchBar?: React.ReactNode; + sort: string; + pageSize: number; + committedQuery: string; + total: number; + page: number; + records: DoiRecord[]; + isLoadingRecords: boolean; + recordError: unknown; + showInitialResultsSkeleton: boolean; + citationCount: DoiMetricState; + viewCount: DoiMetricState; + downloadCount: DoiMetricState; + showAdvancedSearchToggle: boolean; + advancedSearchEnabled: boolean; + onAdvancedSearchChange: (checked: boolean) => void; + onSortChange: (next: SelectOption | null) => void; + onPageChange: (page: number) => void; +}; + +export default function DoiResultsPanel(props: Props) { + const hasCommittedQuery = props.committedQuery.trim().length > 0; + const items = useMemo(() => props.records.map(buildDoiRecordListItem), [props.records]); + + return ( + + +
+ {props.searchBar ?
{props.searchBar}
: null} +
+ + + {props.showAdvancedSearchToggle ? ( + + ) : null} + option.id === props.sort) || null} + loading={false} + onChange={props.onSortChange} + /> + + + +
+
+ + {hasCommittedQuery ? ( + + ) : null} + + {props.recordError ? ( +
+
+ There was an error retrieving DOI records. Check the syntax of your query and try again. +
+
+ ) : hasCommittedQuery ? ( + props.showInitialResultsSkeleton ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ); +} + +function AdvancedSearchToggle(props: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + function handleTriggerClick(event: React.MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + props.onCheckedChange(!props.checked); + } + + function handleTriggerPointerDown(event: React.PointerEvent) { + if ((event.target as HTMLElement).closest("[data-slot='checkbox']")) return; + event.preventDefault(); + event.stopPropagation(); + } + + return ( + + } + className="inline-flex cursor-pointer items-center gap-2 rounded-full px-2 py-1 text-datacite-dark-blue transition-colors hover:bg-gray-200" + onPointerDown={handleTriggerPointerDown} + onClick={handleTriggerClick} + > + props.onCheckedChange(Boolean(checked))} + onClick={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + /> + Enable advanced search + + + + + + +

+ Use {" "} + + query string syntax + + {" "} + to perform advanced queries and filters. +

+
+
+
+
+ ); +} + +function FacetSingleSelect(props: { + label: string; + options: SelectOption[]; + value: SelectOption | null; + loading: boolean; + onChange: (next: SelectOption | null) => void; +}) { + return ( + props.onChange((value as SelectOption) || null)} + itemToStringValue={(item) => item.id} + itemToStringLabel={(item) => item.title} + disabled={props.loading} + > + + {props.label} + + + + No options found. + + {(item) => ( + + {item.title} + + )} + + + + ); +} \ No newline at end of file diff --git a/src/app/dois/DoiSearchBar.tsx b/src/app/dois/DoiSearchBar.tsx new file mode 100644 index 0000000..7e67cdd --- /dev/null +++ b/src/app/dois/DoiSearchBar.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { SearchIcon } from "lucide-react"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@/components/ui/input-group"; + +type Props = { + query: string; + scrollLeft: number; + onInputChange: (event: React.ChangeEvent) => void; + onInputScroll: (event: React.UIEvent) => void; + onInputKeyDown: (event: React.KeyboardEvent) => void; +}; + +export default function DoiSearchBar(props: Props) { + return ( + + + + +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/dois/DoisPageClient.tsx b/src/app/dois/DoisPageClient.tsx new file mode 100644 index 0000000..c6afcc4 --- /dev/null +++ b/src/app/dois/DoisPageClient.tsx @@ -0,0 +1,641 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import DoiFacetsPanel from "@/app/dois/DoiFacetsPanel"; +import DoiResultsPanel from "@/app/dois/DoiResultsPanel"; +import DoiSearchBar from "@/app/dois/DoiSearchBar"; +import { + DOI_DEFAULT_PAGE_SIZE, + DOI_DEFAULT_SORT, + DOI_FACET_CONFIGS, + DOI_FACET_URL_PARAMS, + DOI_PAGE_PARAM, + DOI_PAGE_SIZE_PARAM, + DOI_SORT_PARAM, +} from "@/app/dois/doiConfig"; +import { useDoiFacetValues } from "@/app/dois/useDoiFacetValues"; +import { useDoiRecords } from "@/app/dois/useDoiRecords"; +import { SEARCH_PARAMETERS } from "@/constants"; +import { + buildCombinedDoiQuery, + buildDoiFacetClause, + formatMissingFacetTitle, + getDoiFacetStoredValues, + normalizeDoiBaseQuery, + parseCommaSeparatedParam, + withFixedDoiQuery, +} from "@/util"; +import type { DoiFacetValue, SelectOption } from "@/types"; + +interface Props { + initialQuery: string; + fixedQuery?: string; + basePath?: string; + searchBarMode?: "top" | "below-results"; + showInlineSearchBar?: boolean; + defaultSort?: string; +} + +const DOI_ESCAPABLE_QUERY_PATTERN = /[+\-=&|> { + const rightItem = right[index]; + return ( + leftItem.id === rightItem.id && + leftItem.title === rightItem.title && + leftItem.count === rightItem.count + ); + }); +} + +export default function DoisPageClient({ + initialQuery, + fixedQuery, + basePath = "/dois", + searchBarMode = "top", + showInlineSearchBar = true, + defaultSort = DOI_DEFAULT_SORT, +}: Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [query, setQuery] = useState(initialQuery); + const [scrollLeft, setScrollLeft] = useState(0); + const [selectedFacetValues, setSelectedFacetValues] = useState< + Record + >({}); + const [accordionOpenKeys, setAccordionOpenKeys] = useState([]); + const [interactingFacetKey, setInteractingFacetKey] = useState(null); + const [committedQuery, setCommittedQuery] = useState(initialQuery); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DOI_DEFAULT_PAGE_SIZE); + const [sort, setSort] = useState(DOI_DEFAULT_SORT); + const [isAdvancedSearchEnabled, setIsAdvancedSearchEnabled] = useState(false); + const hasEscapableQueryCharacters = useMemo( + () => DOI_ESCAPABLE_QUERY_PATTERN.test(query), + [query], + ); + const effectiveAdvancedSearch = hasEscapableQueryCharacters && isAdvancedSearchEnabled; + + const activeFacetClauses = useMemo( + () => + DOI_FACET_CONFIGS.map((config) => + buildDoiFacetClause( + config.queryField, + selectedFacetValues[config.key] || [], + config.valueField, + config.valueFormat, + config.valuePrefix, + ), + ).filter(Boolean), + [selectedFacetValues], + ); + + function buildAccordionFacetQuery( + facetKey: string, + nextSelectedFacetValues = selectedFacetValues, + ) { + const facetClauses = DOI_FACET_CONFIGS.filter((config) => config.key !== facetKey) + .map((config) => + buildDoiFacetClause( + config.queryField, + nextSelectedFacetValues[config.key] || [], + config.valueField, + config.valueFormat, + config.valuePrefix, + ), + ) + .filter(Boolean); + + return withFixedDoiQuery( + fixedQuery, + buildCombinedDoiQuery(normalizeDoiBaseQuery(query, effectiveAdvancedSearch), facetClauses), + ); + } + + const facetQueryRequests = useMemo(() => { + const requests = [ + { + id: "chart:published", + facetKey: "published", + query: committedQuery, + }, + { + id: "chart:resourceTypes", + facetKey: "resourceTypes", + query: committedQuery, + }, + ...accordionOpenKeys.map((facetKey) => ({ + id: `accordion:${facetKey}`, + facetKey, + query: buildAccordionFacetQuery(facetKey), + })), + ]; + + const deduped = new Map(); + requests.forEach((request) => { + if (!deduped.has(request.id)) { + deduped.set(request.id, request); + } + }); + + return Array.from(deduped.values()); + }, [accordionOpenKeys, committedQuery, selectedFacetValues, query, effectiveAdvancedSearch, fixedQuery]); + + const facetQueryState = useDoiFacetValues(facetQueryRequests); + + const chartPublicationFacetValues = facetQueryState.valuesById["chart:published"] || []; + const chartResourceTypeFacetValues = facetQueryState.valuesById["chart:resourceTypes"] || []; + + const accordionFacetValues = useMemo( + () => + Object.fromEntries( + accordionOpenKeys.map((facetKey) => [ + facetKey, + facetQueryState.valuesById[`accordion:${facetKey}`] || [], + ]), + ) as Record, + [accordionOpenKeys, facetQueryState.valuesById], + ); + + const accordionFacetLoading = useMemo( + () => + Object.fromEntries( + accordionOpenKeys.map((facetKey) => [ + facetKey, + Boolean( + !facetQueryState.isFetchedById[`accordion:${facetKey}`] && + facetQueryState.isPendingById[`accordion:${facetKey}`], + ), + ]), + ) as Record, + [accordionOpenKeys, facetQueryState.isFetchedById, facetQueryState.isPendingById], + ); + + const facetsLoading = + (!facetQueryState.isFetchedById["chart:published"] && + Boolean(facetQueryState.isPendingById["chart:published"])) || + (!facetQueryState.isFetchedById["chart:resourceTypes"] && + Boolean(facetQueryState.isPendingById["chart:resourceTypes"])); + + const publicationYearData = useMemo(() => { + const source = + chartPublicationFacetValues.length > 0 + ? chartPublicationFacetValues + : (accordionFacetValues.published || []); + + return source + .map((item) => { + const raw = (item.id || item.title || "").trim(); + const year = Number(raw); + if (!/^\d{4}$/.test(raw) || !Number.isFinite(year)) return null; + + return { + year: raw, + count: item.count, + }; + }) + .filter((item): item is { year: string; count: number } => item !== null) + .sort((left, right) => Number(left.year) - Number(right.year)); + }, [chartPublicationFacetValues, accordionFacetValues]); + + const resourceTypeFacetData = useMemo(() => { + const source = + chartResourceTypeFacetValues.length > 0 + ? chartResourceTypeFacetValues + : (accordionFacetValues.resourceTypes || []); + + return source + .map((item) => ({ + type: (item.title || item.id || "Unknown").trim() || "Unknown", + count: item.count, + })) + .filter((item) => item.count > 0) + .sort((left, right) => right.count - left.count); + }, [chartResourceTypeFacetValues, accordionFacetValues]); + + const selectedPublicationYears = useMemo(() => { + const selected = selectedFacetValues.published || []; + + return new Set( + selected + .map((value) => normalizeFacetLabel(value.id || value.title || "")) + .filter(Boolean), + ); + }, [selectedFacetValues]); + + const selectedResourceTypes = useMemo(() => { + const selected = selectedFacetValues.resourceTypes || []; + + return new Set( + selected + .map((value) => normalizeFacetLabel(value.title || value.id || "Unknown") || "Unknown") + .filter(Boolean), + ); + }, [selectedFacetValues]); + + const finalQuery = useMemo( + () => + withFixedDoiQuery( + fixedQuery, + buildCombinedDoiQuery( + normalizeDoiBaseQuery(query, effectiveAdvancedSearch), + activeFacetClauses, + ), + ), + [query, activeFacetClauses, fixedQuery, effectiveAdvancedSearch], + ); + + const { + records, + total, + isLoadingRecords, + recordError, + showInitialResultsSkeleton, + citationCount, + viewCount, + downloadCount, + } = useDoiRecords({ + query: committedQuery, + page, + pageSize, + sort, + }); + + function pushUrl( + nextBaseQuery: string, + nextPage = page, + nextPageSize = pageSize, + nextSort = sort, + nextSelectedFacetValues = selectedFacetValues, + nextAdvancedSearch = isAdvancedSearchEnabled, + ) { + const params = new URLSearchParams(searchParams.toString()); + if (nextBaseQuery.trim()) params.set(SEARCH_PARAMETERS.QUERY, nextBaseQuery.trim()); + else params.delete(SEARCH_PARAMETERS.QUERY); + + DOI_FACET_CONFIGS.forEach((config) => { + const storedValues = getDoiFacetStoredValues( + nextSelectedFacetValues, + config.key, + config.valueField, + ); + const paramName = getFacetParamValue(config.key); + + if (storedValues.length > 0) { + params.set(paramName, storedValues.join(",")); + } else { + params.delete(paramName); + } + }); + + if (nextPageSize !== DOI_DEFAULT_PAGE_SIZE) { + params.set(DOI_PAGE_SIZE_PARAM, String(nextPageSize)); + } else { + params.delete(DOI_PAGE_SIZE_PARAM); + } + + if (nextPage > 1) { + params.set(DOI_PAGE_PARAM, String(nextPage)); + } else { + params.delete(DOI_PAGE_PARAM); + } + + if (nextSort.trim()) { + params.set(DOI_SORT_PARAM, nextSort); + } else { + params.delete(DOI_SORT_PARAM); + } + + const nextHasEscapableQueryCharacters = DOI_ESCAPABLE_QUERY_PATTERN.test(nextBaseQuery); + if (nextAdvancedSearch && nextHasEscapableQueryCharacters) { + params.set("advancedSearch", "true"); + } else { + params.delete("advancedSearch"); + } + + const queryString = params.toString(); + router.push(queryString ? `${basePath}?${queryString}` : basePath, { scroll: false }); + } + + useEffect(() => { + const params = new URLSearchParams(searchParams.toString()); + const queryFromUrl = ( + params.get(SEARCH_PARAMETERS.QUERY) || + params.get("q") || + initialQuery || + "" + ).trim(); + const advancedSearchFromUrl = params.get("advancedSearch") === "true"; + const selectedFacetTitlesFromUrl = Object.fromEntries( + DOI_FACET_CONFIGS.map((config) => [ + config.key, + parseCommaSeparatedParam(params.get(getFacetParamValue(config.key))), + ]), + ) as Record; + const pageFromUrl = Number(params.get(DOI_PAGE_PARAM) || 1); + const pageSizeFromUrl = Number( + params.get(DOI_PAGE_SIZE_PARAM) || DOI_DEFAULT_PAGE_SIZE, + ); + const sortFromUrl = params.get(DOI_SORT_PARAM) || defaultSort || DOI_DEFAULT_SORT; + + setPage(Number.isFinite(pageFromUrl) && pageFromUrl > 0 ? pageFromUrl : 1); + setPageSize( + Number.isFinite(pageSizeFromUrl) ? pageSizeFromUrl : DOI_DEFAULT_PAGE_SIZE, + ); + setSort(sortFromUrl); + + const nextSelectedFacetValues = Object.fromEntries( + DOI_FACET_CONFIGS.map((config) => { + const storedValues = selectedFacetTitlesFromUrl[config.key] || []; + + const selectedForFacet = storedValues.map((storedValue) => ({ + id: storedValue, + title: storedValue, + count: 0, + })); + + return [config.key, selectedForFacet]; + }), + ) as Record; + + const baseEffectiveQuery = buildCombinedDoiQuery( + normalizeDoiBaseQuery(queryFromUrl, advancedSearchFromUrl), + DOI_FACET_CONFIGS.map((config) => + buildDoiFacetClause( + config.queryField, + nextSelectedFacetValues[config.key] || [], + config.valueField, + config.valueFormat, + config.valuePrefix, + ), + ).filter(Boolean), + ); + + setQuery(queryFromUrl); + setIsAdvancedSearchEnabled(advancedSearchFromUrl); + setSelectedFacetValues(nextSelectedFacetValues); + setCommittedQuery(withFixedDoiQuery(fixedQuery, baseEffectiveQuery)); + }, [defaultSort, fixedQuery, initialQuery, searchParams]); + + useEffect(() => { + setSelectedFacetValues((previous) => { + let hasChanges = false; + const next: Record = { ...previous }; + + DOI_FACET_CONFIGS.forEach((config) => { + const selected = previous[config.key] || []; + if (selected.length === 0) return; + + const valueKey = (item: DoiFacetValue) => + config.valueField === "title" ? item.title : item.id; + + const source = config.key === "published" + ? chartPublicationFacetValues + : config.key === "resourceTypes" + ? chartResourceTypeFacetValues + : (accordionFacetValues[config.key] || []); + + const hydrated = selected.map((item) => { + const fromApi = source.find((sourceItem) => valueKey(sourceItem) === valueKey(item)); + if (fromApi) return fromApi; + + if (config.valueField === "id") { + const formattedTitle = formatMissingFacetTitle(item.id); + if (item.title !== formattedTitle) { + return { + ...item, + title: formattedTitle, + }; + } + } + + return item; + }); + + if (!areFacetSelectionsEqual(selected, hydrated)) { + hasChanges = true; + next[config.key] = hydrated; + } + }); + + return hasChanges ? next : previous; + }); + }, [chartPublicationFacetValues, chartResourceTypeFacetValues, accordionFacetValues]); + + function handleInputChange(event: React.ChangeEvent) { + setQuery(event.target.value); + setScrollLeft(event.target.scrollLeft); + } + + function handleInputScroll(event: React.UIEvent) { + setScrollLeft(event.currentTarget.scrollLeft); + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key !== "Enter") return; + + event.preventDefault(); + setPage(1); + setCommittedQuery(finalQuery); + pushUrl(query, 1, pageSize, sort); + } + + function commitFacetSelectionWithValues(nextSelectedFacetValues: Record) { + const nextFacetClauses = DOI_FACET_CONFIGS.map((config) => + buildDoiFacetClause( + config.queryField, + nextSelectedFacetValues[config.key] || [], + config.valueField, + config.valueFormat, + config.valuePrefix, + ), + ).filter(Boolean); + const nextQuery = withFixedDoiQuery( + fixedQuery, + buildCombinedDoiQuery( + normalizeDoiBaseQuery(query, effectiveAdvancedSearch), + nextFacetClauses, + ), + ); + + setPage(1); + setCommittedQuery(nextQuery); + pushUrl(query, 1, pageSize, sort, nextSelectedFacetValues); + } + + function handleAccordionFacetToggle(facetKey: string, item: DoiFacetValue, checked: boolean) { + const config = DOI_FACET_CONFIGS.find((entry) => entry.key === facetKey); + const compareValue = (value: DoiFacetValue) => + config?.valueField === "title" ? value.title : value.id; + const current = selectedFacetValues[facetKey] || []; + const itemValue = compareValue(item); + const exists = current.some((value) => compareValue(value) === itemValue); + const nextValues = checked + ? exists + ? current + : [...current, item] + : current.filter((value) => compareValue(value) !== itemValue); + const merged = { ...selectedFacetValues, [facetKey]: nextValues }; + + setSelectedFacetValues(merged); + setInteractingFacetKey(facetKey); + commitFacetSelectionWithValues(merged); + } + + function commitChartFacetSelection(facetKey: string, nextFacetValues: DoiFacetValue[]) { + const merged = { ...selectedFacetValues, [facetKey]: nextFacetValues }; + + setSelectedFacetValues(merged); + setInteractingFacetKey(facetKey); + commitFacetSelectionWithValues(merged); + } + + function buildChartFacetValue(facetKey: "resourceTypes" | "published", clickedValue: string) { + const normalizedClickedValue = normalizeFacetLabel(clickedValue); + const source = [ + ...(facetKey === "published" ? chartPublicationFacetValues : chartResourceTypeFacetValues), + ...(accordionFacetValues[facetKey] || []), + ...(selectedFacetValues[facetKey] || []), + ]; + + const fromSource = source.find((item) => { + if (facetKey === "resourceTypes") { + return normalizeFacetLabel(item.title || item.id || "Unknown") === normalizedClickedValue; + } + + return normalizeFacetLabel(item.id || item.title || "") === normalizedClickedValue; + }); + + if (fromSource) return fromSource; + + return { + id: normalizedClickedValue, + title: normalizedClickedValue, + count: 0, + } satisfies DoiFacetValue; + } + + function handleResourceTypeChartClick(resourceType: string) { + const nextValue = buildChartFacetValue("resourceTypes", resourceType); + commitChartFacetSelection("resourceTypes", [nextValue]); + } + + function handlePublicationYearChartClick(year: string) { + const nextValue = buildChartFacetValue("published", year); + commitChartFacetSelection("published", [nextValue]); + } + + function handleSortChange(value: SelectOption | null) { + const nextSort = value?.id || ""; + setPage(1); + setSort(nextSort); + pushUrl(query, 1, pageSize, nextSort); + } + + function handleAdvancedSearchToggle(checked: boolean) { + setIsAdvancedSearchEnabled(checked); + setPage(1); + + const nextQuery = withFixedDoiQuery( + fixedQuery, + buildCombinedDoiQuery( + normalizeDoiBaseQuery(query, hasEscapableQueryCharacters && checked), + activeFacetClauses, + ), + ); + + setCommittedQuery(nextQuery); + pushUrl(query, 1, pageSize, sort, selectedFacetValues, checked); + } + + function handlePageChange(nextPage: number) { + if (nextPage === page || nextPage < 1) return; + setPage(nextPage); + pushUrl(query, nextPage, pageSize, sort); + } + + useEffect(() => { + setInteractingFacetKey(null); + }, [committedQuery]); + + const showTopSearchBar = searchBarMode === "top"; + + return ( +
+
+ {showTopSearchBar ? ( + + ) : null} + +
+
+ + ) : undefined} + showAdvancedSearchToggle={hasEscapableQueryCharacters} + advancedSearchEnabled={effectiveAdvancedSearch} + onAdvancedSearchChange={handleAdvancedSearchToggle} + sort={sort} + pageSize={pageSize} + committedQuery={committedQuery} + total={total} + page={page} + records={records} + isLoadingRecords={isLoadingRecords} + recordError={recordError} + showInitialResultsSkeleton={showInitialResultsSkeleton} + citationCount={citationCount} + viewCount={viewCount} + downloadCount={downloadCount} + onSortChange={handleSortChange} + onPageChange={handlePageChange} + /> + { + setAccordionOpenKeys(openKeys); + }} + onFacetToggle={handleAccordionFacetToggle} + onPublicationYearClick={handlePublicationYearChartClick} + onResourceTypeClick={handleResourceTypeChartClick} + /> +
+
+
+
+ ); +} diff --git a/src/app/dois/[...id]/DoiTabbedClient.tsx b/src/app/dois/[...id]/DoiTabbedClient.tsx new file mode 100644 index 0000000..56fdd47 --- /dev/null +++ b/src/app/dois/[...id]/DoiTabbedClient.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { + Eye, + GitCompare, + Puzzle, + Quote, + Shapes, + BookCheck, + Building2, +} from "lucide-react"; +import { H3 } from "@/components/datacite/Headings"; +import { Card } from "@/components/ui/card"; +import { Suspense, useMemo } from "react"; +import DoisPageClient from "@/app/dois/DoisPageClient"; +import GenericTabbedPage, { type GenericTabItem } from "@/components/GenericTabbedPage"; +import { + fetchDoisTotal, +} from "@/data/fetch"; +import { asNumber } from "@/util"; +import type { DoiHeaderData, HeaderInfo } from "@/types"; +import IndexedInList from "./IndexedInList"; + +type Props = { + id: string; + headerData: DoiHeaderData; +}; + +function quoteForQuery(value: string): string { + return `\"${value.replace(/\"/g, "\\\\\"")}\"`; +} + +export default function DoiTabbedClient({ id, headerData }: Props) { + const headerInfo: HeaderInfo = { + title: headerData.title, + id: headerData.id, + labels: [ + { + type: "type", + content: headerData.resourceTypeGeneral, + icon: , + }, + { + type: "year", + content: headerData.publicationYear, + icon: , + }, + { + type: "publisher", + content: headerData.publisher, + icon: , + }, + { + type: "version", + content: headerData.version, + icon: , + }, + { + type: "citations", + content: headerData.citationCount, + icon: , + }, + ], + }; + + const otherVersionsQuery = + `version_ids:(${quoteForQuery(id)}) OR version_of_ids:(${quoteForQuery(id)})` + + (headerData.versionOfRelationshipIds.length > 0 + ? ` OR version_of_ids:(${headerData.versionOfRelationshipIds + .map((value) => quoteForQuery(value)) + .join(" OR ")})` + : ""); + + const tabs = useMemo( + (): GenericTabItem[] => [ + { + value: "Overview", + label: "Overview", + icon: , + content: ( + +
+ Metadata overview could go here +
+ +

Indexed In

+ +
+ +

Citation String Generator

+
+
+
+
+ ), + }, + { + value: "cite-by", + label: "Cited By", + groupLabel: "Impact", + icon: , + getBadgeValue: async () => asNumber(await fetchDoisTotal(`reference_ids:(${id})`)), + content: ( + + + + ), + }, + { + value: "views-downloads", + label: "Views & Downloads", + groupLabel: "Impact", + icon: , + content: ( + + +
Views and downloads metrics could go here
+
+
+ ), + }, + { + value: "references", + label: "References", + icon: , + groupLabel: "Connections", + getBadgeValue: async () => asNumber(await fetchDoisTotal(`citation_ids:(${id})`)), + content: ( + + + + ), + }, + { + value: "other-versions", + label: "Other Versions", + icon: , + groupLabel: "Connections", + getBadgeValue: async () => asNumber(await fetchDoisTotal(otherVersionsQuery)), + content: ( + + + + ), + }, + { + value: "part-of", + label: "Part Of", + icon: , + groupLabel: "Connections", + getBadgeValue: async () => asNumber(await fetchDoisTotal(`part_ids:(${id})`)), + content: ( + + + + ), + }, + { + value: "parts", + label: "Parts", + icon: , + groupLabel: "Connections", + getBadgeValue: async () => asNumber(await fetchDoisTotal(`part_of_ids:(${id})`)), + content: ( + + + + ), + }, + ], + [id, otherVersionsQuery], + ); + + return ( + + ); +} diff --git a/src/app/dois/[...id]/IndexedInList.tsx b/src/app/dois/[...id]/IndexedInList.tsx new file mode 100644 index 0000000..3a1dc47 --- /dev/null +++ b/src/app/dois/[...id]/IndexedInList.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Separator as BaseSeparator } from "@base-ui/react/separator"; +import { Quote, SquareArrowOutUpRight } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + fetchOpenAireWorkByDoi, + fetchOpenAlexWorkByDoi, + fetchOpenCitationsByDoi, +} from "@/data/fetch"; +import { asNumber } from "@/util"; + +type IndexedSourceItem = { + key: string; + label: string; + href: string; + metric?: number; +}; + +const INDEXED_ROW_HEIGHT_CLASS = "h-[3.25rem]"; + +function IndexedInLoadingRow() { + return ( +
+ + +
+ ); +} + +function IndexedSourceRow(props: { item: IndexedSourceItem }) { + return ( + + + {props.item.label} + + + {typeof props.item.metric === "number" ? ( + + + {asNumber(props.item.metric)} + + ) : ( + . + )} + + ); +} + +function buildRows(items: IndexedSourceItem[], loadingByKey: Record) { + const order = ["openalex", "openaire", "opencitations"] as const; + + return order.flatMap((key) => { + if (loadingByKey[key]) { + return [{ key: `${key}-loading`, node: }]; + } + + const item = items.find((entry) => entry.key === key); + if (!item) return []; + + return [{ key: item.key, node: }]; + }); +} + +export default function IndexedInList(props: { doi: string }) { + const trimmedDoi = props.doi.trim(); + + const openAlexWork = useQuery({ + queryKey: ["openalex", "work-by-doi", trimmedDoi], + queryFn: () => fetchOpenAlexWorkByDoi(trimmedDoi), + enabled: trimmedDoi.length > 0, + staleTime: 5 * 60 * 1000, + }); + + const openAireWork = useQuery({ + queryKey: ["openaire", "work-by-doi", trimmedDoi], + queryFn: () => fetchOpenAireWorkByDoi(trimmedDoi), + enabled: trimmedDoi.length > 0, + staleTime: 5 * 60 * 1000, + }); + + const openCitationsWork = useQuery({ + queryKey: ["opencitations", "work-by-doi", trimmedDoi], + queryFn: () => fetchOpenCitationsByDoi(trimmedDoi), + enabled: trimmedDoi.length > 0, + staleTime: 5 * 60 * 1000, + }); + + const items: IndexedSourceItem[] = []; + if (openAlexWork.data?.id) { + items.push({ + key: "openalex", + label: "OpenAlex", + href: openAlexWork.data.id, + metric: openAlexWork.data.citedByCount, + }); + } + + if (openAireWork.data && trimmedDoi) { + items.push({ + key: "openaire", + label: "OpenAire", + href: `https://explore.openaire.eu/search/publication?pid=${encodeURIComponent(trimmedDoi)}`, + metric: openAireWork.data.citedByCount, + }); + } + + if (openCitationsWork.data?.id) { + items.push({ + key: "opencitations", + label: "OpenCitations", + href: openCitationsWork.data.id, + metric: openCitationsWork.data.citedByCount, + }); + } + + const loadingByKey = { + openalex: trimmedDoi.length > 0 && openAlexWork.isPending && !openAlexWork.data, + openaire: trimmedDoi.length > 0 && openAireWork.isPending && !openAireWork.data, + opencitations: + trimmedDoi.length > 0 && openCitationsWork.isPending && !openCitationsWork.data, + } as const; + + const rows = buildRows(items, loadingByKey); + if (rows.length === 0) { + return

No external resources were found.

; + } + + return ( +
    + {rows.map((row, index) => ( +
  • + {row.node} + {index < rows.length - 1 ? ( + + ) : null} +
  • + ))} +
+ ); +} diff --git a/src/app/dois/[...id]/OpenAireLink.tsx b/src/app/dois/[...id]/OpenAireLink.tsx new file mode 100644 index 0000000..b31f867 --- /dev/null +++ b/src/app/dois/[...id]/OpenAireLink.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Quote, SquareArrowOutUpRight } from "lucide-react"; +import { fetchOpenAireWorkByDoi } from "@/data/fetch"; +import { asNumber } from "@/util"; + +type Props = { + doi: string; +}; + +export default function OpenAireLink({ doi }: Props) { + const trimmedDoi = doi.trim(); + const openAireUrl = `https://explore.openaire.eu/search/publication?pid=${encodeURIComponent(trimmedDoi)}`; + + const openAireWork = useQuery({ + queryKey: ["openaire", "work-by-doi", doi], + queryFn: () => fetchOpenAireWorkByDoi(doi), + enabled: trimmedDoi.length > 0, + staleTime: 5 * 60 * 1000, + }); + + if (!trimmedDoi) return null; + + return ( +
+ + OpenAire + + + {openAireWork.data ? ( + + + {asNumber(openAireWork.data.citedByCount)} + + ) : null} +
+ ); +} diff --git a/src/app/dois/[...id]/OpenAlexLink.tsx b/src/app/dois/[...id]/OpenAlexLink.tsx new file mode 100644 index 0000000..be09873 --- /dev/null +++ b/src/app/dois/[...id]/OpenAlexLink.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Quote, SquareArrowOutUpRight } from "lucide-react"; +import { fetchOpenAlexWorkByDoi } from "@/data/fetch"; +import { asNumber } from "@/util"; + +type Props = { + doi: string; +}; + +export default function OpenAlexLink({ doi }: Props) { + const openAlexWork = useQuery({ + queryKey: ["openalex", "work-by-doi", doi], + queryFn: () => fetchOpenAlexWorkByDoi(doi), + enabled: doi.trim().length > 0, + staleTime: 5 * 60 * 1000, + }); + + if (!openAlexWork.data?.id) return null; + + return ( +
+ + OpenAlex + + + + + {asNumber(openAlexWork.data.citedByCount)} + +
+ ); +} diff --git a/src/app/dois/[...id]/doiRecord.ts b/src/app/dois/[...id]/doiRecord.ts new file mode 100644 index 0000000..026a8cf --- /dev/null +++ b/src/app/dois/[...id]/doiRecord.ts @@ -0,0 +1,19 @@ +import { cache } from "react"; +import { fetchDoiRecord, fetchDoisRecords } from "@/data/fetch"; +import type { DoiRecord } from "@/types"; + +type DoiRecordResponse = { + data?: DoiRecord; +}; + +export const getDoiRecordWithFallback = cache(async (doi: string) => { + const directRecord = await fetchDoiRecord(doi).catch(() => null) as DoiRecordResponse | null; + if (directRecord?.data) return directRecord; + + const fallback = await fetchDoisRecords(`doi:${doi}`, { + pageSize: 1, + }).catch(() => null); + + const firstFallbackRecord = fallback?.data?.[0]; + return firstFallbackRecord ? { data: firstFallbackRecord } : null; +}); diff --git a/src/app/dois/[...id]/layout.tsx b/src/app/dois/[...id]/layout.tsx new file mode 100644 index 0000000..44b2828 --- /dev/null +++ b/src/app/dois/[...id]/layout.tsx @@ -0,0 +1,55 @@ +import { notFound } from "next/navigation"; +import Breadcrumbs from "@/components/Breadcrumbs"; +import { fetchEntity } from "@/data/fetch"; +import { getDoiRecordWithFallback } from "./doiRecord"; + +interface LayoutProps { + params: Promise<{ id: string[] }>; + children: React.ReactNode; +} + +export default async function DoiLayout({ + params, + children, +}: LayoutProps) { + const { id } = await params; + const doiPath = id.join("/"); + + try { + const doiRecord = await getDoiRecordWithFallback(doiPath); + + if (!doiRecord?.data) notFound(); + + const clientId = doiRecord.data.relationships?.client?.data?.id; + const doiTitle = doiRecord.data.attributes?.titles?.[0]?.title || doiPath; + let clientParent = null; + + if (clientId) { + try { + const client = await fetchEntity(clientId); + if (client) { + clientParent = client; + } + } catch { + } + } + + const doiEntity = { + id: doiPath, + name: doiTitle, + type: "doi", + role: "client", + parent: clientParent, + children: [], + }; + + return ( + <> + + {children} + + ); + } catch { + notFound(); + } +} diff --git a/src/app/dois/[...id]/page.tsx b/src/app/dois/[...id]/page.tsx new file mode 100644 index 0000000..2cdfd93 --- /dev/null +++ b/src/app/dois/[...id]/page.tsx @@ -0,0 +1,36 @@ +import DoiTabbedClient from "./DoiTabbedClient"; +import { getDoiRecordWithFallback } from "./doiRecord"; +import type { DoiHeaderData } from "@/types"; +import { asNumber } from "@/util"; + +interface PageProps { + params: Promise<{ id: string[] }>; +} + +export default async function DoiPage({ params }: PageProps) { + const { id: idArray } = await params; + const id = Array.isArray(idArray) ? idArray.join("/") : idArray; + + const doiRecord = await getDoiRecordWithFallback(id); + const attributes = doiRecord?.data?.attributes; + const versionOfRelationshipIds = + doiRecord?.data?.relationships?.versionOf?.data + ?.map((entry) => entry?.id) + .filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) || []; + + const headerData: DoiHeaderData = { + title: attributes?.titles?.[0]?.title || "Untitled", + id: `https://doi.org/${id}`, + resourceTypeGeneral: attributes?.types?.resourceTypeGeneral || "Unknown Type", + publicationYear: attributes?.publicationYear?.toString() || "Unknown Year", + publisher: attributes?.publisher || "Unknown Publisher", + version: attributes?.version || "", + citationCount: asNumber(attributes?.citationCount || 0), + versionOfRelationshipIds, + }; + + return ; +} diff --git a/src/app/dois/doiConfig.tsx b/src/app/dois/doiConfig.tsx new file mode 100644 index 0000000..71ce07a --- /dev/null +++ b/src/app/dois/doiConfig.tsx @@ -0,0 +1,113 @@ +import { + BookCheck, + BookKey, + CalendarIcon, + Globe, + Languages, + PackageOpen, + Shapes, + Tag, + User, +} from "lucide-react"; +import type { DoiFacetConfig, SelectOption } from "@/types"; + +export const DOI_RESOURCE_TYPE_PARAM = "resourceType"; +export const DOI_PUBLISHED_PARAM = "published"; +export const DOI_PAGE_SIZE_PARAM = "pageSize"; +export const DOI_PAGE_PARAM = "page"; +export const DOI_SORT_PARAM = "sort"; + +export const DOI_DEFAULT_PAGE_SIZE = 25; +export const DOI_DEFAULT_SORT = "-updated"; + +export const DOI_FACET_URL_PARAMS: Record = { + resourceTypes: DOI_RESOURCE_TYPE_PARAM, + published: DOI_PUBLISHED_PARAM, +}; + +export const DOI_PAGE_SIZE_OPTIONS: SelectOption[] = [ + { id: "25", title: "25" }, + { id: "50", title: "50" }, + { id: "250", title: "250" }, + { id: "1000", title: "1000" }, +]; + +export const DOI_SORT_OPTIONS: SelectOption[] = [ + { id: "relevance", title: "Relevance" }, + { id: "title", title: "Title (A-Z)" }, + { id: "-title", title: "Title (Z-A)" }, + { id: "-created", title: "Created (Newest)" }, + { id: "created", title: "Created (Oldest)" }, + { id: "-updated", title: "Updated (Newest)" }, + { id: "updated", title: "Updated (Oldest)" }, + { id: "-citation-count", title: "Most Cited" }, + { id: "-view-count", title: "Most Viewed" }, + { id: "-download-count", title: "Most Downloaded" }, +]; + +export const DOI_FACET_CONFIGS: DoiFacetConfig[] = [ + { + key: "resourceTypes", + label: "Resource Types", + queryField: "resource_type_id", + valueField: "id", + icon: , + }, + { + key: "published", + label: "Publication Year", + queryField: "publication_year", + icon: , + }, + { + key: "affiliations", + label: "Affiliations", + queryField: "affiliation_id", + valueField: "id", + icon: , + }, + { + key: "creatorsAndContributors", + label: "Creators & Contributors", + queryField: "creators_and_contributors.nameIdentifiers.nameIdentifier", + valueField: "id", + icon: , + }, + { + key: "languages", + label: "Languages", + queryField: "language", + valueField: "id", + icon: , + }, + { + key: "prefixes", + label: "Prefixes", + queryField: "prefix", + valueField: "id", + icon: , + }, + { + key: "clients", + label: "Repositories", + queryField: "client.id", + valueField: "id", + icon: , + }, + { + key: "created", + label: "Record Created Year", + queryField: "created", + valueField: "id", + valueFormat: "year-range", + icon: , + }, + { + key: "schemaVersions", + label: "Schema Versions", + queryField: "schemaVersion", + valueField: "id", + valuePrefix: "http://datacite.org/schema/kernel-", + icon: , + }, +]; \ No newline at end of file diff --git a/src/app/dois/useDoiFacetValues.ts b/src/app/dois/useDoiFacetValues.ts new file mode 100644 index 0000000..1d31bf7 --- /dev/null +++ b/src/app/dois/useDoiFacetValues.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useRef } from "react"; +import { keepPreviousData, useQueries } from "@tanstack/react-query"; +import { fetchDoiFacetValues } from "@/data/fetch"; +import type { DoiFacetValue } from "@/types"; + +export type DoiFacetQueryRequest = { + id: string; + facetKey: string; + query: string; + enabled?: boolean; +}; + +type DoiFacetQueryState = { + valuesById: Record; + isPendingById: Record; + isFetchingById: Record; + isFetchedById: Record; +}; + +function normalizeFacetQuery(query: string) { + return query.trim(); +} + +export function getDoiFacetQueryKey(facetKey: string, query: string) { + return ["dois-page", "facet-values", facetKey, normalizeFacetQuery(query)] as const; +} + +export function useDoiFacetValues(requests: DoiFacetQueryRequest[]): DoiFacetQueryState { + const lastKnownValuesRef = useRef>({}); + + const queryResults = useQueries({ + queries: requests.map((request) => { + const normalizedQuery = normalizeFacetQuery(request.query); + + return { + queryKey: getDoiFacetQueryKey(request.facetKey, normalizedQuery), + queryFn: () => fetchDoiFacetValues(request.facetKey, normalizedQuery), + enabled: request.enabled ?? true, + staleTime: 30 * 1000, + placeholderData: keepPreviousData, + }; + }), + }); + + const valuesById: Record = {}; + const isPendingById: Record = {}; + const isFetchingById: Record = {}; + const isFetchedById: Record = {}; + + requests.forEach((request, index) => { + const result = queryResults[index]; + const nextValues = result.data; + + if (nextValues !== undefined) { + lastKnownValuesRef.current[request.id] = nextValues; + } + + valuesById[request.id] = nextValues ?? lastKnownValuesRef.current[request.id] ?? []; + isPendingById[request.id] = result.isPending; + isFetchingById[request.id] = result.isFetching; + isFetchedById[request.id] = result.isFetched; + }); + + return { + valuesById, + isPendingById, + isFetchingById, + isFetchedById, + }; +} diff --git a/src/app/dois/useDoiRecords.ts b/src/app/dois/useDoiRecords.ts new file mode 100644 index 0000000..88e1c68 --- /dev/null +++ b/src/app/dois/useDoiRecords.ts @@ -0,0 +1,68 @@ +"use client"; + +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { fetchDoiMetricTotal, fetchDoisRecords } from "@/data/fetch"; +import type { DoiMetricFacet, DoiMetricState } from "@/types"; + +type UseDoiRecordsOptions = { + query: string; + page: number; + pageSize: number; + sort: string; +}; + + +function useDoiMetric(metric: DoiMetricFacet, query: string, enabled: boolean): DoiMetricState { + const metricQuery = useQuery({ + queryKey: ["dois-page", "metric", metric, query], + queryFn: () => fetchDoiMetricTotal(metric, query), + enabled, + staleTime: 30 * 1000, + }); + + return { + value: enabled && metricQuery.data != null ? metricQuery.data : null, + isLoading: enabled && metricQuery.data == null && metricQuery.isPending, + isError: metricQuery.isError, + }; +} + +export function useDoiRecords({ query, page, pageSize, sort }: UseDoiRecordsOptions) { + const resolvedQuery = query.trim(); + const hasCommittedQuery = resolvedQuery.length > 0; + + const recordsQuery = useQuery({ + queryKey: ["dois-page", "records", resolvedQuery, page, pageSize, sort], + queryFn: () => + fetchDoisRecords(resolvedQuery, { + pageSize, + sort, + pageNumber: page, + }), + enabled: hasCommittedQuery, + placeholderData: keepPreviousData, + staleTime: 30 * 1000, + }); + + const citationCount = useDoiMetric("citationCount", resolvedQuery, hasCommittedQuery); + const viewCount = useDoiMetric("viewCount", resolvedQuery, hasCommittedQuery); + const downloadCount = useDoiMetric("downloadCount", resolvedQuery, hasCommittedQuery); + + const records = hasCommittedQuery ? recordsQuery.data?.data || [] : []; + const total = hasCommittedQuery ? recordsQuery.data?.meta?.total || 0 : 0; + const showInitialResultsSkeleton = + hasCommittedQuery && recordsQuery.data == null && recordsQuery.isPending; + const isLoadingRecords = hasCommittedQuery && recordsQuery.isFetching; + + return { + hasCommittedQuery, + records, + total, + isLoadingRecords, + recordError: recordsQuery.error, + showInitialResultsSkeleton, + citationCount, + viewCount, + downloadCount, + }; +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 1c9dbe1..33eacee 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -38,9 +38,10 @@ --color-card: var(--card); --color-primary-light-blue: #00b1e2; --color-primary-dark-blue: #243b54; - --color-datacite-blue-light: #00b1e2; + --color-datacite-blue-light: #00B1E2; --color-datacite-blue-dark: #243b54; --color-datacite-blue-muted: #0071B2; + --color-datacite-dark-gray: #2C2A29; --color-datacite-gray: #F6F7F8; --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4bc56c0..28ecc3a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -25,7 +25,7 @@ export default function RootLayout({ >
-
+
{children}