diff --git a/src/app/citations/[...doi]/layout.tsx b/src/app/citations/[...doi]/layout.tsx new file mode 100644 index 0000000..e69c43e --- /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/[...doi]">) { + + return ( + <> + {children} + + ); +} diff --git a/src/app/citations/[...doi]/page.tsx b/src/app/citations/[...doi]/page.tsx new file mode 100644 index 0000000..8b6e0e1 --- /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 = 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 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..bfdf2f4 --- /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: { 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

+
+ +
+
+
+
+
+

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..6efb01e --- /dev/null +++ b/src/components/DoiRecordList.tsx @@ -0,0 +1,85 @@ +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; + publicationYear?: string; + publisher?: string; + agency?: string; + }; +}; + +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} {record.attributes.agency && " · via " + 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 a4ab218..bbc2682 100644 --- a/src/data/fetch.ts +++ b/src/data/fetch.ts @@ -1,3 +1,4 @@ + import { useQuery as useTanstackQuery } from "@tanstack/react-query"; import { COMPLETENESS_FIELDS } from "@/constants"; import { useQuery } from "@/hooks"; @@ -471,3 +472,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