From bb5ef8e857dc7ae7e73bbcf02abb44704d966b7d Mon Sep 17 00:00:00 2001 From: codycooperross <50597551+codycooperross@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:19:24 -0400 Subject: [PATCH 1/7] Citation prototyping experiments --- src/app/citations/[...doi]/layout.tsx | 15 +++ src/app/citations/[...doi]/page.tsx | 92 +++++++++++++++++++ src/app/citationsByEntity/[id]/Header.tsx | 13 +++ src/app/citationsByEntity/[id]/layout.tsx | 23 +++++ src/app/citationsByEntity/[id]/page.tsx | 82 +++++++++++++++++ src/components/DoiRecordList.tsx | 82 +++++++++++++++++ src/components/EventFeed.tsx | 106 ++++++++++++++++++++++ src/data/fetch.ts | 56 ++++++++++++ 8 files changed, 469 insertions(+) create mode 100644 src/app/citations/[...doi]/layout.tsx create mode 100644 src/app/citations/[...doi]/page.tsx create mode 100644 src/app/citationsByEntity/[id]/Header.tsx create mode 100644 src/app/citationsByEntity/[id]/layout.tsx create mode 100644 src/app/citationsByEntity/[id]/page.tsx create mode 100644 src/components/DoiRecordList.tsx create mode 100644 src/components/EventFeed.tsx diff --git a/src/app/citations/[...doi]/layout.tsx b/src/app/citations/[...doi]/layout.tsx new file mode 100644 index 0000000..19af824 --- /dev/null +++ b/src/app/citations/[...doi]/layout.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; +import ActionButtons from "@/components/ActionButtons"; +import Breadcrumbs from "@/components/Breadcrumbs"; +import { fetchEntity } from "@/data/fetch"; + +export default async function Layout({ + children, +}: LayoutProps<"citations">) { + + return ( + <> + {children} + + ); +} diff --git a/src/app/citations/[...doi]/page.tsx b/src/app/citations/[...doi]/page.tsx new file mode 100644 index 0000000..785a256 --- /dev/null +++ b/src/app/citations/[...doi]/page.tsx @@ -0,0 +1,92 @@ +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 { + fetchDoiRecord, + fetchEvents, + fetchDoisRecords, + fetchDois, +} from "@/data/fetch"; +import DoiRegistrationsChart from "@/components/DoiRegistrationsChart"; +import ResourceTypesChart from "@/components/ResourceTypesChart"; +import EventFeed from "@/components/EventFeed"; +import { Filter } from "lucide-react"; +import { DoiRecordList } from "@/components/DoiRecordList"; +import { H2 } from "@/components/datacite/Headings"; + +interface PageProps { + params: { doi: string }; +} + +export default async function Page({ params }: PageProps) { + const { doi } = await params; + const doi_id = doi.join("/"); + + // Fetch the DOI record and events + const [record, eventsResult, doisRecords] = await Promise.all([ + fetchDoiRecord(doi_id), + fetchEvents(doi_id), + fetchDoisRecords("reference_ids:" + doi_id), + ]); + const citationsOverTime = record?.data?.attributes?.citationsOverTime || []; + let chartData: { year: string; count: number }[] = []; + if (citationsOverTime.length > 0) { + const yearNums = citationsOverTime.map((item: { year: string }) => + parseInt(item.year, 10), + ); + const minYear = Math.min(...yearNums); + const maxYear = new Date().getFullYear(); + const yearToCount: Record = {}; + citationsOverTime.forEach((item: { year: string; total: number }) => { + yearToCount[item.year] = item.total; + }); + for (let y = minYear; y <= maxYear; y++) { + chartData.push({ + year: y.toString(), + count: yearToCount[y.toString()] ?? 0, + }); + } + } + const events = eventsResult?.data || []; + + return ( + <> +
+

+ {record.data.attributes.titles[0].title} +

+
+ {record.data.attributes.doi} +
+
+
+
+
+

Citations Over Time

+ {record.data.attributes.citationCount > 0 && ( +
+ +
+ ) || ( +

No citation data available.

+ )} +
+
+

Event Feed

+ +
+
+
+
+

+ Available Citation Records +

+ {doisRecords.meta.resourceTypes && } + +
+
+
+ + ); +} diff --git a/src/app/citationsByEntity/[id]/Header.tsx b/src/app/citationsByEntity/[id]/Header.tsx new file mode 100644 index 0000000..fcb073e --- /dev/null +++ b/src/app/citationsByEntity/[id]/Header.tsx @@ -0,0 +1,13 @@ +import { H2 } from "@/components/datacite/Headings"; +import type { Entity } from "@/types"; + +export default function Header(props: { entity: Entity }) { + return ( +
+

{props.entity.name}

+
+ {props.entity.id} +
+
+ ); +} diff --git a/src/app/citationsByEntity/[id]/layout.tsx b/src/app/citationsByEntity/[id]/layout.tsx new file mode 100644 index 0000000..39c6525 --- /dev/null +++ b/src/app/citationsByEntity/[id]/layout.tsx @@ -0,0 +1,23 @@ +import { notFound } from "next/navigation"; +import ActionButtons from "@/components/ActionButtons"; +import Breadcrumbs from "@/components/Breadcrumbs"; +import { fetchEntity } from "@/data/fetch"; +import Header from "./Header"; + +export default async function Layout({ + params, + children, +}: LayoutProps<"/[id]">) { + const { id } = await params; + + // Check if entity exists + const entity = await fetchEntity(id); + if (!entity) notFound(); + + return ( + <> +
+ {children} + + ); +} diff --git a/src/app/citationsByEntity/[id]/page.tsx b/src/app/citationsByEntity/[id]/page.tsx new file mode 100644 index 0000000..5f30f2a --- /dev/null +++ b/src/app/citationsByEntity/[id]/page.tsx @@ -0,0 +1,82 @@ +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 { fetchDoisRecords, fetchEntity } from "@/data/fetch"; +import { fetchEntityCitations } from "@/data/fetch"; +import DoiRegistrationsChart from "@/components/DoiRegistrationsChart"; +import { DoiRecordList } from "@/components/DoiRecordList"; + +export default async function Page({ + params, + searchParams, +}: PageProps<"/[id]">) { + const { id } = await params; + + const [doisRecords] = await Promise.all([ + fetchEntityCitations("provider.id:" + id + " OR client_id:" + id), + ]); + +const citationsOverTime = doisRecords?.meta?.citations || []; +let chartData: { year: string; count: number }[] = []; +if (citationsOverTime.length > 0) { + // Map citations to { year, count } + const mapped = citationsOverTime.map((item: { id: string; count: number }) => ({ + year: item.id, + count: item.count, + })); + const yearNums = mapped.map((item) => parseInt(item.year, 10)); + const minYear = Math.min(...yearNums); + const maxYear = new Date().getFullYear(); + const yearToCount: Record = {}; + mapped.forEach((item) => { + yearToCount[item.year] = item.count; + }); + for (let y = minYear; y <= maxYear; y++) { + chartData.push({ + year: y.toString(), + count: yearToCount[y.toString()] ?? 0, + }); + } +} + + // 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; + + if (Array.isArray(value)) + for (const v of value) urlSearchParams.append(key, v); + else urlSearchParams.append(key, value); + }); + + redirect(`/${id.toLowerCase()}?${urlSearchParams.toString()}`); + } + + const entity = await fetchEntity(id); + if (!entity) return null; + + return ( +
+
+
+
+

Citations By Record Publication Year

+
+ +
+
+
+
+
+

Most Cited Records

+
+ +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/DoiRecordList.tsx b/src/components/DoiRecordList.tsx new file mode 100644 index 0000000..428c85b --- /dev/null +++ b/src/components/DoiRecordList.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import Link from "next/link"; + +export type DoiRecord = { + id: string; + attributes: { + titles: { title: string }[]; + doi: string; + descriptions?: { description: string }[]; + types: { resourceTypeGeneral?: string }; + citationCount?: number; + viewCount?: number; + downloadCount?: number; + }; +}; + +type DoiRecordListProps = { + records: DoiRecord[]; +}; + +export function DoiRecordList({ records }: DoiRecordListProps) { + if (!records || records.length === 0) { + return

No citations found.

; + } + return ( +
+ {records.map((record, idx) => ( +
+ + {idx < records.length - 1 && ( +
+
+
+ )} +
+ ))} +
+ ); +} + +type DoiRecordItemProps = { + record: DoiRecord; +}; + +export function DoiRecordItem({ record }: DoiRecordItemProps) { + return ( + +
+
+ {record.attributes.titles[0].title} +
+
+ https://doi.org/{record.attributes.doi} +
+
+ {record.attributes.publicationYear} · {record.attributes.publisher} · via {record.attributes.agency && record.attributes.agency.charAt(0).toUpperCase() + record.attributes.agency.slice(1)} +
+
+ {record.attributes.descriptions?.[0]?.description} +
+ +
+ {record.attributes.types.resourceTypeGeneral ? ( + + {record.attributes.types.resourceTypeGeneral} + + ) : null} + + Citations: {record.attributes.citationCount} + Views: {record.attributes.viewCount} + Downloads: {record.attributes.downloadCount} + +
+
+ + ); +} diff --git a/src/components/EventFeed.tsx b/src/components/EventFeed.tsx new file mode 100644 index 0000000..3f08d53 --- /dev/null +++ b/src/components/EventFeed.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import Link from "next/link"; + +export type EventFeedItem = { + id: string; + type: string; + attributes: { + "subj-id": string; + "obj-id": string; + "source-id": string; + "relation-type-id": string; + total: number; + "message-action": string; + license: string; + "occurred-at": string; + timestamp: string; + }; + relationships: { + subj: { data: { id: string; type: string } }; + obj: { data: { id: string; type: string } }; + }; +}; + +export type EventFeedProps = { + events: EventFeedItem[]; + doi: string; +}; + +const relationTypeLabel: Record = { + references: "references", + cites: "cites", + "is-authored-by": "is authored by", +}; + +export default function EventFeed({ events, doi }: EventFeedProps) { + return ( +
+
    + {events.map((event, idx) => ( +
  • + {idx !== events.length - 1 && ( +
  • + ))} +
+
+ ); +} diff --git a/src/data/fetch.ts b/src/data/fetch.ts index a3d8908..b973c12 100644 --- a/src/data/fetch.ts +++ b/src/data/fetch.ts @@ -1,3 +1,4 @@ + import { useQuery as useTanstackQuery } from "@tanstack/react-query"; import { ALL_OF_DATACITE_ID, @@ -545,3 +546,58 @@ export function useOther(entity: Entity) { buildPlaceholderData(formatOther, COMPLETENESS_FIELDS.OTHER), ); } + +// Always fetch from production DataCite API +export async function fetchDoiRecord(doi: string) { + const url = `https://api.datacite.org/dois/${encodeURIComponent(doi)}`; + const response = await fetch(url, { + method: "GET", + headers: { accept: "application/vnd.api+json" }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch DOI record: ${response.statusText}`); + } + return response.json(); +} + +export async function fetchEvents(doi: string) { + const url = `https://api.datacite.org/events?doi=${encodeURIComponent(doi)}&page[size]=1000&query=NOT source_id:datacite-resolution`; + const response = await fetch(url, { + method: "GET", + headers: { accept: "application/vnd.api+json" }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch DOI events: ${response.statusText}`); + } + return response.json(); +} + +export async function fetchDoisRecords(query: string) { + const url = `https://api.datacite.org/dois?query=${query}&page[size]=25&include_other_registration_agencies=true&disable-facets=false&facets=resourceTypes`; + console.log("Fetching DOIs with query:", url); + const response = await fetch(url, { + method: "GET", + headers: { accept: "application/vnd.api+json" }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch DOIs records: ${response.statusText}`); + } +const data = await response.json(); +console.log("DOIs records response:", data); + return data; +} + +export async function fetchEntityCitations(query: string) { + const url = `https://api.datacite.org/dois?query=${query}&page[size]=25&disable-facets=false&facets=citations&sort=-citation-count`; + console.log("Fetching DOIs with query:", url); + const response = await fetch(url, { + method: "GET", + headers: { accept: "application/vnd.api+json" }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch DOIs records: ${response.statusText}`); + } +const data = await response.json(); +console.log("DOIs records response:", data.meta); + return data; +} \ No newline at end of file From 32e2936c5ef81544888420bae57fa6edb47a5e5b Mon Sep 17 00:00:00 2001 From: codycooperross <50597551+codycooperross@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:30:30 -0400 Subject: [PATCH 2/7] Build errors --- src/app/citations/[...doi]/layout.tsx | 2 +- src/app/citations/[...doi]/page.tsx | 4 ++-- src/app/citationsByEntity/[id]/page.tsx | 4 ++-- src/components/DoiRecordList.tsx | 3 +++ 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/citations/[...doi]/layout.tsx b/src/app/citations/[...doi]/layout.tsx index 19af824..e69c43e 100644 --- a/src/app/citations/[...doi]/layout.tsx +++ b/src/app/citations/[...doi]/layout.tsx @@ -5,7 +5,7 @@ import { fetchEntity } from "@/data/fetch"; export default async function Layout({ children, -}: LayoutProps<"citations">) { +}: LayoutProps<"/citations/[...doi]">) { return ( <> diff --git a/src/app/citations/[...doi]/page.tsx b/src/app/citations/[...doi]/page.tsx index 785a256..8b6e0e1 100644 --- a/src/app/citations/[...doi]/page.tsx +++ b/src/app/citations/[...doi]/page.tsx @@ -21,8 +21,8 @@ interface PageProps { export default async function Page({ params }: PageProps) { const { doi } = await params; - const doi_id = doi.join("/"); - + const doi_id = Array.isArray(doi) ? doi.join("/") : doi; + // Fetch the DOI record and events const [record, eventsResult, doisRecords] = await Promise.all([ fetchDoiRecord(doi_id), diff --git a/src/app/citationsByEntity/[id]/page.tsx b/src/app/citationsByEntity/[id]/page.tsx index 5f30f2a..bfdf2f4 100644 --- a/src/app/citationsByEntity/[id]/page.tsx +++ b/src/app/citationsByEntity/[id]/page.tsx @@ -25,11 +25,11 @@ if (citationsOverTime.length > 0) { year: item.id, count: item.count, })); - const yearNums = mapped.map((item) => parseInt(item.year, 10)); + const yearNums = mapped.map((item: { year: string; count: number }) => parseInt(item.year, 10)); const minYear = Math.min(...yearNums); const maxYear = new Date().getFullYear(); const yearToCount: Record = {}; - mapped.forEach((item) => { + mapped.forEach((item: { year: string; count: number }) => { yearToCount[item.year] = item.count; }); for (let y = minYear; y <= maxYear; y++) { diff --git a/src/components/DoiRecordList.tsx b/src/components/DoiRecordList.tsx index 428c85b..2c8c60a 100644 --- a/src/components/DoiRecordList.tsx +++ b/src/components/DoiRecordList.tsx @@ -11,6 +11,9 @@ export type DoiRecord = { citationCount?: number; viewCount?: number; downloadCount?: number; + publicationYear?: string; + publisher?: string; + agency?: string; }; }; From 15e76032a19c51f9709f2b7c37f1a402a36083ef Mon Sep 17 00:00:00 2001 From: codycooperross <50597551+codycooperross@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:45:48 -0400 Subject: [PATCH 3/7] Only display agency if available --- src/components/DoiRecordList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DoiRecordList.tsx b/src/components/DoiRecordList.tsx index 2c8c60a..6efb01e 100644 --- a/src/components/DoiRecordList.tsx +++ b/src/components/DoiRecordList.tsx @@ -61,7 +61,7 @@ export function DoiRecordItem({ record }: DoiRecordItemProps) { https://doi.org/{record.attributes.doi}
- {record.attributes.publicationYear} · {record.attributes.publisher} · via {record.attributes.agency && record.attributes.agency.charAt(0).toUpperCase() + record.attributes.agency.slice(1)} + {record.attributes.publicationYear} · {record.attributes.publisher} {record.attributes.agency && " · via " + record.attributes.agency.charAt(0).toUpperCase() + record.attributes.agency.slice(1)}
{record.attributes.descriptions?.[0]?.description} From 7707e60dbe2fa18b47e02f459e466c342904004a Mon Sep 17 00:00:00 2001 From: codycooperross <50597551+codycooperross@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:19:23 -0400 Subject: [PATCH 4/7] Tweaks --- src/app/citationsByEntity/[id]/page.tsx | 10 ++- src/app/dois/[...doi]/layout.tsx | 15 ++++ src/app/dois/[...doi]/page.tsx | 100 ++++++++++++++++++++++++ src/components/DoiRecordList.tsx | 2 +- src/components/EventFeed.tsx | 18 ++--- src/data/fetch.ts | 19 ++++- 6 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 src/app/dois/[...doi]/layout.tsx create mode 100644 src/app/dois/[...doi]/page.tsx diff --git a/src/app/citationsByEntity/[id]/page.tsx b/src/app/citationsByEntity/[id]/page.tsx index bfdf2f4..3e2129b 100644 --- a/src/app/citationsByEntity/[id]/page.tsx +++ b/src/app/citationsByEntity/[id]/page.tsx @@ -64,7 +64,10 @@ if (citationsOverTime.length > 0) {

Citations By Record Publication Year

- + { chartData.length > 0 ? + : +

No citations found.

+ }
@@ -72,7 +75,10 @@ if (citationsOverTime.length > 0) {

Most Cited Records

- + { doisRecords.data.length > 0 ? + : +

No citations found.

+ }
diff --git a/src/app/dois/[...doi]/layout.tsx b/src/app/dois/[...doi]/layout.tsx new file mode 100644 index 0000000..832fd7f --- /dev/null +++ b/src/app/dois/[...doi]/layout.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; +import ActionButtons from "@/components/ActionButtons"; +import Breadcrumbs from "@/components/Breadcrumbs"; +import { fetchEntity } from "@/data/fetch"; + +export default async function Layout({ + children, +}: LayoutProps<"/dois/[...doi]">) { + + return ( + <> + {children} + + ); +} diff --git a/src/app/dois/[...doi]/page.tsx b/src/app/dois/[...doi]/page.tsx new file mode 100644 index 0000000..72f32e1 --- /dev/null +++ b/src/app/dois/[...doi]/page.tsx @@ -0,0 +1,100 @@ +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 { + fetchDoiRecord, + fetchEvents, + fetchDoisRecords, + fetchDois, + fetchEntity, +} from "@/data/fetch"; +import DoiRegistrationsChart from "@/components/DoiRegistrationsChart"; +import ResourceTypesChart from "@/components/ResourceTypesChart"; +import EventFeed from "@/components/EventFeed"; +import { Filter } from "lucide-react"; +import { DoiRecordList } from "@/components/DoiRecordList"; +import { H2 } from "@/components/datacite/Headings"; +import { Breadcrumb } from "@/components/ui/breadcrumb"; +import Breadcrumbs from "@/components/Breadcrumbs"; + +interface PageProps { + params: { doi: string }; +} + +export default async function Page({ params }: PageProps) { + const { doi } = await params; + const doi_id = Array.isArray(doi) ? doi.join("/") : doi; + + // Fetch the DOI record and events + const [record, eventsResult, doisRecords] = await Promise.all([ + fetchDoiRecord(doi_id), + fetchEvents(doi_id), + fetchDoisRecords("reference_ids:" + doi_id), + ]); + + const clientId = record?.data?.relationships?.client?.data?.id; + const entity = clientId ? await fetchEntity(clientId) : null; + + const citationsOverTime = record?.data?.attributes?.citationsOverTime || []; + let chartData: { year: string; count: number }[] = []; + if (citationsOverTime.length > 0) { + const yearNums = citationsOverTime.map((item: { year: string }) => + parseInt(item.year, 10), + ); + const minYear = Math.min(...yearNums); + const maxYear = new Date().getFullYear(); + const yearToCount: Record = {}; + citationsOverTime.forEach((item: { year: string; total: number }) => { + yearToCount[item.year] = item.total; + }); + for (let y = minYear; y <= maxYear; y++) { + chartData.push({ + year: y.toString(), + count: yearToCount[y.toString()] ?? 0, + }); + } + } + const events = eventsResult?.data || []; + + return ( + <> + +
+

+ {record.data.attributes.titles[0].title} +

+
+ {record.data.attributes.doi} +
+
+
+
+
+

Citations Over Time

+ {record.data.attributes.citationCount > 0 && ( +
+ +
+ ) || ( +

No citation data available.

+ )} +
+
+

Event Feed

+ +
+
+
+
+

+ Available Records Citing This Work +

+ {doisRecords.meta.resourceTypes && } + +
+
+
+ + ); +} diff --git a/src/components/DoiRecordList.tsx b/src/components/DoiRecordList.tsx index 6efb01e..b06ca54 100644 --- a/src/components/DoiRecordList.tsx +++ b/src/components/DoiRecordList.tsx @@ -48,7 +48,7 @@ type DoiRecordItemProps = { export function DoiRecordItem({ record }: DoiRecordItemProps) { return ( )} - -
-
+ +
+
{event.attributes["subj-id"].startsWith("https://doi.org/") ? ( - {event.attributes["subj-id"].includes(doi) ? : ""} + {event.attributes["subj-id"].replace("https://doi.org/", "" ) === doi ? : ""} {event.attributes["subj-id"].replace("https://doi.org/", "")} ) : ( @@ -60,7 +60,7 @@ export default function EventFeed({ events, doi }: EventFeedProps) { className="font-semibold bg-[#e6f0fa] text-[#003366] rounded-full px-3 py-1 text-xs inline-block text-center max-w-xs overflow-hidden truncate" title={event.attributes["subj-id"]} > - {event.attributes["subj-id"].includes(doi) ? : ""} + {event.attributes["subj-id"].replace("https://doi.org/", "" ) === doi ? : ""} {event.attributes["subj-id"].replace("https://doi.org/", "")} )} @@ -71,13 +71,13 @@ export default function EventFeed({ events, doi }: EventFeedProps) { {event.attributes["obj-id"].startsWith("https://doi.org/") ? ( - {event.attributes["obj-id"].includes(doi) ? : ""} + {event.attributes["obj-id"].replace("https://doi.org/", "" ) === doi ? : ""} {event.attributes["obj-id"].replace("https://doi.org/", "")} ) : ( @@ -85,7 +85,7 @@ export default function EventFeed({ events, doi }: EventFeedProps) { className="font-semibold bg-[#e6f0fa] text-[#003366] rounded-full px-3 py-1 text-xs inline-block text-center max-w-xs overflow-hidden truncate" title={event.attributes["obj-id"]} > - {event.attributes["obj-id"].includes(doi) ? : ""} + {event.attributes["obj-id"].replace("https://doi.org/", "" ) === doi ? : ""} {event.attributes["obj-id"].replace("https://doi.org/", "")} )} diff --git a/src/data/fetch.ts b/src/data/fetch.ts index b973c12..72ab49c 100644 --- a/src/data/fetch.ts +++ b/src/data/fetch.ts @@ -561,7 +561,7 @@ export async function fetchDoiRecord(doi: string) { } export async function fetchEvents(doi: string) { - const url = `https://api.datacite.org/events?doi=${encodeURIComponent(doi)}&page[size]=1000&query=NOT source_id:datacite-resolution`; + const url = `https://api.datacite.org/events?page[size]=1000&query=(subj_id:"https://doi.org/${doi}" OR obj_id:"https://doi.org/${doi}") AND NOT source_id:datacite-resolution`; const response = await fetch(url, { method: "GET", headers: { accept: "application/vnd.api+json" }, @@ -587,8 +587,23 @@ console.log("DOIs records response:", data); return data; } +export async function fetchDoisRecordsForMetadata(query: string) { + const url = `https://api.datacite.org/dois?query=${query}&page[size]=25`; + console.log("Fetching DOIs with query:", url); + const response = await fetch(url, { + method: "GET", + headers: { accept: "application/vnd.api+json" }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch DOIs records: ${response.statusText}`); + } +const data = await response.json(); +console.log("DOIs records response:", data); + return data; +} + export async function fetchEntityCitations(query: string) { - const url = `https://api.datacite.org/dois?query=${query}&page[size]=25&disable-facets=false&facets=citations&sort=-citation-count`; + const url = `https://api.datacite.org/dois?query=${query} AND citationCount:>0&page[size]=25&disable-facets=false&facets=citations&sort=-citation-count`; console.log("Fetching DOIs with query:", url); const response = await fetch(url, { method: "GET", From ed8cc82b61f0ea93b1191e3a492d38673c86b5cb Mon Sep 17 00:00:00 2001 From: codycooperross <50597551+codycooperross@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:51:30 -0400 Subject: [PATCH 5/7] Prototype of metadata investigation --- src/app/globals.css | 2 +- src/components/PresentBar.tsx | 290 +++++++++++++++++++++++++++++----- src/util.ts | 3 + 3 files changed, 251 insertions(+), 44 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 1c9dbe1..7c8523c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -38,7 +38,7 @@ --color-card: var(--card); --color-primary-light-blue: #00b1e2; --color-primary-dark-blue: #243b54; - --color-datacite-blue-light: #00b1e2; + --color-datacite-blue-light: #037AAD; --color-datacite-blue-dark: #243b54; --color-datacite-blue-muted: #0071B2; --color-datacite-gray: #F6F7F8; diff --git a/src/components/PresentBar.tsx b/src/components/PresentBar.tsx index 16adea2..52cfa19 100644 --- a/src/components/PresentBar.tsx +++ b/src/components/PresentBar.tsx @@ -10,13 +10,21 @@ import { } from "recharts"; import { HighImpactBadge } from "@/components/Badges"; import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; -import { CHART } from "@/constants"; +import { API_URL_DATACITE, CHART, COMMONS_URL } from "@/constants"; import { cn } from "@/lib/utils"; -import { asRoundedPercent } from "@/util"; +import { asNumber, asRoundedPercent } from "@/util"; +import * as Dialog from "@radix-ui/react-dialog"; +import { Button } from "@base-ui/react/button"; +import { Ban, Check, ExternalLink, MinusSquare, ScanSearch, SquareCheck, XIcon } from "lucide-react"; +import { useParams } from "next/navigation"; +import { H3, H4 } from "./datacite/Headings"; export interface Props { property: string; present: number; + metadataField?: string; + withCount?: number; + withoutCount?: number; isHighImpact?: boolean; className?: string; } @@ -27,62 +35,112 @@ const chartConfig = { present: { label: "Present" }, } satisfies ChartConfig; + + + +import React from "react"; + + export default function PresentBar(props: Props) { - const { property, present, isHighImpact = false } = props; + const { + property, + present, + metadataField, + withCount, + withoutCount, + isHighImpact = false, + } = props; const containerHeight = BAR.size + 10; - const data = [{ property, present }]; + const params = useParams<{ id?: string | string[] }>(); + const entityId = + typeof params.id === "string" + ? params.id + : Array.isArray(params.id) + ? params.id[0] + : undefined; + + const entityScope = entityId + ? `${entityId.includes(".") ? "client.id" : "provider.id"}:${entityId}` + : undefined; + + const [showDrawer, setShowDrawer] = React.useState(false); return (
{ + if (!showDrawer) { + setShowDrawer(true); + } + }} > {property}{" "} - + {asRoundedPercent(present)} - - - asRoundedPercent(value)} - tick={{ textAnchor: "end", dx: 30 }} - hide - /> - - +
+ - - - - + + asRoundedPercent(value)} + tick={{ textAnchor: "end", dx: 30 }} + hide + /> + + + + + + +
+ +
+
); } @@ -95,3 +153,149 @@ function CategoryLabel(props: LabelProps) { ); } + + +export function MetadataPropertyModal({ + metadataProperty, + metadataField, + entityScope, + withCount, + withoutCount, + open, + onOpenChange, +}: { + metadataProperty: string; + metadataField?: string; + entityScope?: string; + withCount?: number; + withoutCount?: number; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const title = metadataField || metadataProperty; + const toCamelCaseIfSnake = (value: string) => + value.includes("_") + ? value + .toLowerCase() + .replace(/_([a-z0-9])/g, (_, char: string) => char.toUpperCase()) + : value; + + const withPropertyQuery = [entityScope, `${title}:*`] + .filter(Boolean) + .join(" AND "); + const missingPropertyQuery = [entityScope, `NOT ${title}:*`] + .filter(Boolean) + .join(" AND "); + + const apiWithPropertyUrl = `${API_URL_DATACITE}/dois?query=${encodeURIComponent(withPropertyQuery)}`; + const apiMissingPropertyUrl = `${API_URL_DATACITE}/dois?query=${encodeURIComponent(missingPropertyQuery)}`; + const commonsWithPropertyUrl = `${COMMONS_URL}/doi.org?query=${encodeURIComponent(withPropertyQuery)}`; + const commonsMissingPropertyUrl = `${COMMONS_URL}/doi.org?query=${encodeURIComponent(missingPropertyQuery)}`; + + function openInNewWindow(url: string) { + window.open(url, "_blank", "noopener,noreferrer"); + } + + return ( + + + + + +

+ {title.split(".").map((part, idx, arr) => ( + + {toCamelCaseIfSnake(part)} + {idx < arr.length - 1 && ( + > + )} + + ))} +

+
+ +
+ + + + + + + + + ); +} diff --git a/src/util.ts b/src/util.ts index abab098..b2a6bb5 100644 --- a/src/util.ts +++ b/src/util.ts @@ -71,7 +71,10 @@ function toPresentProps(item?: Present): PresentProps { return { property: field?.label || item.field, + metadataField: item.field, present: item.percent, + withCount: item.count, + withoutCount: item.absent_count, isHighImpact: field?.isHighImpact || false, }; } From b896dc39db8726d167a87bff8109554c7523d6bc Mon Sep 17 00:00:00 2001 From: codycooperross <50597551+codycooperross@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:13:10 +0200 Subject: [PATCH 6/7] Frontend prototyping --- package.json | 4 +- src/app/Header.tsx | 20 +- src/app/[id]/page.tsx | 56 +- src/app/citations/[...doi]/layout.tsx | 15 - src/app/citations/[...doi]/page.tsx | 92 --- src/app/citationsByEntity/[id]/Header.tsx | 13 - src/app/citationsByEntity/[id]/page.tsx | 88 --- src/app/dois/DoiFacetsPanel.tsx | 168 +++++ src/app/dois/DoiMetricsSummary.tsx | 53 ++ src/app/dois/DoiResultsPanel.tsx | 213 ++++++ src/app/dois/DoiSearchBar.tsx | 56 ++ src/app/dois/DoisPageClient.tsx | 641 +++++++++++++++++ src/app/dois/[...doi]/layout.tsx | 15 - src/app/dois/[...doi]/page.tsx | 100 --- src/app/dois/[...id]/DoiTabbedClient.tsx | 208 ++++++ src/app/dois/[...id]/IndexedInList.tsx | 147 ++++ src/app/dois/[...id]/OpenAireLink.tsx | 44 ++ src/app/dois/[...id]/OpenAlexLink.tsx | 39 ++ src/app/dois/[...id]/doiRecord.ts | 19 + src/app/dois/[...id]/layout.tsx | 55 ++ src/app/dois/[...id]/page.tsx | 36 + src/app/dois/doiConfig.tsx | 113 +++ src/app/dois/useDoiFacetValues.ts | 72 ++ src/app/dois/useDoiRecords.ts | 68 ++ src/app/globals.css | 3 +- src/app/layout.tsx | 2 +- src/app/orcid.org/[id]/OrcidPageClient.tsx | 107 +++ src/app/orcid.org/[id]/layout.tsx | 40 ++ src/app/orcid.org/[id]/orcidRecord.ts | 41 ++ src/app/orcid.org/[id]/page.tsx | 14 + src/app/org-repo/[id]/Header.tsx | 14 + src/app/org-repo/[id]/OrgRepoPageClient.tsx | 169 +++++ .../[id]/layout.tsx | 6 +- src/app/org-repo/[id]/orgRepoRecord.ts | 6 + src/app/org-repo/[id]/page.tsx | 62 ++ src/app/page.tsx | 13 +- src/app/ror.org/[id]/RorPageClient.tsx | 126 ++++ src/app/ror.org/[id]/layout.tsx | 40 ++ src/app/ror.org/[id]/page.tsx | 15 + src/app/ror.org/[id]/rorRecord.ts | 43 ++ src/app/search/SearchPageClient.tsx | 335 +++++++++ src/app/search/layout.tsx | 25 + src/app/search/page.tsx | 30 + src/components/ActionButtons.tsx | 15 +- src/components/Badges.tsx | 3 + src/components/Breadcrumbs.tsx | 81 ++- src/components/DistributionChart.tsx | 61 +- src/components/DoiExportMenu.tsx | 325 +++++++++ src/components/DoiRecordList.tsx | 184 +++-- src/components/DoiRecordListSkeleton.tsx | 43 ++ src/components/DoiRegistrationsChart.tsx | 50 +- src/components/GenericHeader.tsx | 35 + src/components/GenericTabbedPage.tsx | 251 +++++++ src/components/GlobalSearch.tsx | 422 ++++++++++++ src/components/PaginationControls.tsx | 165 +++++ src/components/PresentBar.tsx | 347 +++------- src/components/RadialChart.tsx | 149 ++-- src/components/ResourceTypesChart.tsx | 111 ++- src/components/cards/Cards.tsx | 19 + src/components/cards/ChartsCard.tsx | 4 +- .../metadata/MetadataDrilldownDrawer.tsx | 67 ++ .../metadata/MetadataDrilldownPopover.tsx | 80 +++ .../metadata/MetadataEntityScopeContext.tsx | 29 + src/components/ui/accordion.tsx | 70 ++ src/components/ui/checkbox.tsx | 25 + src/components/ui/combobox.tsx | 2 +- src/components/ui/drawer.tsx | 131 ++++ src/components/ui/toolbar.tsx | 43 ++ src/data/fetch.ts | 646 +++++++++++++++++- src/lib/resultItems.tsx | 120 ++++ src/types.ts | 199 ++++++ src/util.ts | 160 ++++- yarn.lock | 559 ++++++++------- 73 files changed, 6711 insertions(+), 1111 deletions(-) delete mode 100644 src/app/citations/[...doi]/layout.tsx delete mode 100644 src/app/citations/[...doi]/page.tsx delete mode 100644 src/app/citationsByEntity/[id]/Header.tsx delete mode 100644 src/app/citationsByEntity/[id]/page.tsx create mode 100644 src/app/dois/DoiFacetsPanel.tsx create mode 100644 src/app/dois/DoiMetricsSummary.tsx create mode 100644 src/app/dois/DoiResultsPanel.tsx create mode 100644 src/app/dois/DoiSearchBar.tsx create mode 100644 src/app/dois/DoisPageClient.tsx delete mode 100644 src/app/dois/[...doi]/layout.tsx delete mode 100644 src/app/dois/[...doi]/page.tsx create mode 100644 src/app/dois/[...id]/DoiTabbedClient.tsx create mode 100644 src/app/dois/[...id]/IndexedInList.tsx create mode 100644 src/app/dois/[...id]/OpenAireLink.tsx create mode 100644 src/app/dois/[...id]/OpenAlexLink.tsx create mode 100644 src/app/dois/[...id]/doiRecord.ts create mode 100644 src/app/dois/[...id]/layout.tsx create mode 100644 src/app/dois/[...id]/page.tsx create mode 100644 src/app/dois/doiConfig.tsx create mode 100644 src/app/dois/useDoiFacetValues.ts create mode 100644 src/app/dois/useDoiRecords.ts create mode 100644 src/app/orcid.org/[id]/OrcidPageClient.tsx create mode 100644 src/app/orcid.org/[id]/layout.tsx create mode 100644 src/app/orcid.org/[id]/orcidRecord.ts create mode 100644 src/app/orcid.org/[id]/page.tsx create mode 100644 src/app/org-repo/[id]/Header.tsx create mode 100644 src/app/org-repo/[id]/OrgRepoPageClient.tsx rename src/app/{citationsByEntity => org-repo}/[id]/layout.tsx (75%) create mode 100644 src/app/org-repo/[id]/orgRepoRecord.ts create mode 100644 src/app/org-repo/[id]/page.tsx create mode 100644 src/app/ror.org/[id]/RorPageClient.tsx create mode 100644 src/app/ror.org/[id]/layout.tsx create mode 100644 src/app/ror.org/[id]/page.tsx create mode 100644 src/app/ror.org/[id]/rorRecord.ts create mode 100644 src/app/search/SearchPageClient.tsx create mode 100644 src/app/search/layout.tsx create mode 100644 src/app/search/page.tsx create mode 100644 src/components/DoiExportMenu.tsx create mode 100644 src/components/DoiRecordListSkeleton.tsx create mode 100644 src/components/GenericHeader.tsx create mode 100644 src/components/GenericTabbedPage.tsx create mode 100644 src/components/GlobalSearch.tsx create mode 100644 src/components/PaginationControls.tsx create mode 100644 src/components/metadata/MetadataDrilldownDrawer.tsx create mode 100644 src/components/metadata/MetadataDrilldownPopover.tsx create mode 100644 src/components/metadata/MetadataEntityScopeContext.tsx create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/drawer.tsx create mode 100644 src/components/ui/toolbar.tsx create mode 100644 src/lib/resultItems.tsx diff --git a/package.json b/package.json index f520961..f5e0aa9 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", 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/citations/[...doi]/layout.tsx b/src/app/citations/[...doi]/layout.tsx deleted file mode 100644 index e69c43e..0000000 --- a/src/app/citations/[...doi]/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { notFound } from "next/navigation"; -import ActionButtons from "@/components/ActionButtons"; -import Breadcrumbs from "@/components/Breadcrumbs"; -import { fetchEntity } from "@/data/fetch"; - -export default async function Layout({ - children, -}: LayoutProps<"/citations/[...doi]">) { - - return ( - <> - {children} - - ); -} diff --git a/src/app/citations/[...doi]/page.tsx b/src/app/citations/[...doi]/page.tsx deleted file mode 100644 index 8b6e0e1..0000000 --- a/src/app/citations/[...doi]/page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 { - fetchDoiRecord, - fetchEvents, - fetchDoisRecords, - fetchDois, -} from "@/data/fetch"; -import DoiRegistrationsChart from "@/components/DoiRegistrationsChart"; -import ResourceTypesChart from "@/components/ResourceTypesChart"; -import EventFeed from "@/components/EventFeed"; -import { Filter } from "lucide-react"; -import { DoiRecordList } from "@/components/DoiRecordList"; -import { H2 } from "@/components/datacite/Headings"; - -interface PageProps { - params: { doi: string }; -} - -export default async function Page({ params }: PageProps) { - const { doi } = await params; - const doi_id = Array.isArray(doi) ? doi.join("/") : doi; - - // Fetch the DOI record and events - const [record, eventsResult, doisRecords] = await Promise.all([ - fetchDoiRecord(doi_id), - fetchEvents(doi_id), - fetchDoisRecords("reference_ids:" + doi_id), - ]); - const citationsOverTime = record?.data?.attributes?.citationsOverTime || []; - let chartData: { year: string; count: number }[] = []; - if (citationsOverTime.length > 0) { - const yearNums = citationsOverTime.map((item: { year: string }) => - parseInt(item.year, 10), - ); - const minYear = Math.min(...yearNums); - const maxYear = new Date().getFullYear(); - const yearToCount: Record = {}; - citationsOverTime.forEach((item: { year: string; total: number }) => { - yearToCount[item.year] = item.total; - }); - for (let y = minYear; y <= maxYear; y++) { - chartData.push({ - year: y.toString(), - count: yearToCount[y.toString()] ?? 0, - }); - } - } - const events = eventsResult?.data || []; - - return ( - <> -
-

- {record.data.attributes.titles[0].title} -

-
- {record.data.attributes.doi} -
-
-
-
-
-

Citations Over Time

- {record.data.attributes.citationCount > 0 && ( -
- -
- ) || ( -

No citation data available.

- )} -
-
-

Event Feed

- -
-
-
-
-

- Available Citation Records -

- {doisRecords.meta.resourceTypes && } - -
-
-
- - ); -} diff --git a/src/app/citationsByEntity/[id]/Header.tsx b/src/app/citationsByEntity/[id]/Header.tsx deleted file mode 100644 index fcb073e..0000000 --- a/src/app/citationsByEntity/[id]/Header.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { H2 } from "@/components/datacite/Headings"; -import type { Entity } from "@/types"; - -export default function Header(props: { entity: Entity }) { - return ( -
-

{props.entity.name}

-
- {props.entity.id} -
-
- ); -} diff --git a/src/app/citationsByEntity/[id]/page.tsx b/src/app/citationsByEntity/[id]/page.tsx deleted file mode 100644 index 3e2129b..0000000 --- a/src/app/citationsByEntity/[id]/page.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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 { fetchDoisRecords, fetchEntity } from "@/data/fetch"; -import { fetchEntityCitations } from "@/data/fetch"; -import DoiRegistrationsChart from "@/components/DoiRegistrationsChart"; -import { DoiRecordList } from "@/components/DoiRecordList"; - -export default async function Page({ - params, - searchParams, -}: PageProps<"/[id]">) { - const { id } = await params; - - const [doisRecords] = await Promise.all([ - fetchEntityCitations("provider.id:" + id + " OR client_id:" + id), - ]); - -const citationsOverTime = doisRecords?.meta?.citations || []; -let chartData: { year: string; count: number }[] = []; -if (citationsOverTime.length > 0) { - // Map citations to { year, count } - const mapped = citationsOverTime.map((item: { id: string; count: number }) => ({ - year: item.id, - count: item.count, - })); - const yearNums = mapped.map((item: { year: string; count: number }) => parseInt(item.year, 10)); - const minYear = Math.min(...yearNums); - const maxYear = new Date().getFullYear(); - const yearToCount: Record = {}; - mapped.forEach((item: { year: string; count: number }) => { - yearToCount[item.year] = item.count; - }); - for (let y = minYear; y <= maxYear; y++) { - chartData.push({ - year: y.toString(), - count: yearToCount[y.toString()] ?? 0, - }); - } -} - - // 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; - - if (Array.isArray(value)) - for (const v of value) urlSearchParams.append(key, v); - else urlSearchParams.append(key, value); - }); - - redirect(`/${id.toLowerCase()}?${urlSearchParams.toString()}`); - } - - const entity = await fetchEntity(id); - if (!entity) return null; - - return ( -
-
-
-
-

Citations By Record Publication Year

-
- { chartData.length > 0 ? - : -

No citations found.

- } -
-
-
-
-
-

Most Cited Records

-
- { doisRecords.data.length > 0 ? - : -

No citations found.

- } -
-
-
-
-
- ); -} \ No newline at end of file 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/[...doi]/layout.tsx b/src/app/dois/[...doi]/layout.tsx deleted file mode 100644 index 832fd7f..0000000 --- a/src/app/dois/[...doi]/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { notFound } from "next/navigation"; -import ActionButtons from "@/components/ActionButtons"; -import Breadcrumbs from "@/components/Breadcrumbs"; -import { fetchEntity } from "@/data/fetch"; - -export default async function Layout({ - children, -}: LayoutProps<"/dois/[...doi]">) { - - return ( - <> - {children} - - ); -} diff --git a/src/app/dois/[...doi]/page.tsx b/src/app/dois/[...doi]/page.tsx deleted file mode 100644 index 72f32e1..0000000 --- a/src/app/dois/[...doi]/page.tsx +++ /dev/null @@ -1,100 +0,0 @@ -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 { - fetchDoiRecord, - fetchEvents, - fetchDoisRecords, - fetchDois, - fetchEntity, -} from "@/data/fetch"; -import DoiRegistrationsChart from "@/components/DoiRegistrationsChart"; -import ResourceTypesChart from "@/components/ResourceTypesChart"; -import EventFeed from "@/components/EventFeed"; -import { Filter } from "lucide-react"; -import { DoiRecordList } from "@/components/DoiRecordList"; -import { H2 } from "@/components/datacite/Headings"; -import { Breadcrumb } from "@/components/ui/breadcrumb"; -import Breadcrumbs from "@/components/Breadcrumbs"; - -interface PageProps { - params: { doi: string }; -} - -export default async function Page({ params }: PageProps) { - const { doi } = await params; - const doi_id = Array.isArray(doi) ? doi.join("/") : doi; - - // Fetch the DOI record and events - const [record, eventsResult, doisRecords] = await Promise.all([ - fetchDoiRecord(doi_id), - fetchEvents(doi_id), - fetchDoisRecords("reference_ids:" + doi_id), - ]); - - const clientId = record?.data?.relationships?.client?.data?.id; - const entity = clientId ? await fetchEntity(clientId) : null; - - const citationsOverTime = record?.data?.attributes?.citationsOverTime || []; - let chartData: { year: string; count: number }[] = []; - if (citationsOverTime.length > 0) { - const yearNums = citationsOverTime.map((item: { year: string }) => - parseInt(item.year, 10), - ); - const minYear = Math.min(...yearNums); - const maxYear = new Date().getFullYear(); - const yearToCount: Record = {}; - citationsOverTime.forEach((item: { year: string; total: number }) => { - yearToCount[item.year] = item.total; - }); - for (let y = minYear; y <= maxYear; y++) { - chartData.push({ - year: y.toString(), - count: yearToCount[y.toString()] ?? 0, - }); - } - } - const events = eventsResult?.data || []; - - return ( - <> - -
-

- {record.data.attributes.titles[0].title} -

-
- {record.data.attributes.doi} -
-
-
-
-
-

Citations Over Time

- {record.data.attributes.citationCount > 0 && ( -
- -
- ) || ( -

No citation data available.

- )} -
-
-

Event Feed

- -
-
-
-
-

- Available Records Citing This Work -

- {doisRecords.meta.resourceTypes && } - -
-
-
- - ); -} 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 7c8523c..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: #037AAD; + --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}