Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -31,7 +31,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^20",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
Expand Down
20 changes: 13 additions & 7 deletions src/app/Header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="py-4 px-6">
<H1 className="w-max">
<header className="flex bg-datacite-blue-dark items-center gap-6 px-6 py-4">
{/* <H1 className="w-max shrink-0">
<Link href="/" prefetch>
<Image src={logo} alt="DataCite logo" height={35} className="" />
</Link>
</H1>
</H1> */}
<div className="flex min-w-0 flex-1 justify-center">
<Suspense fallback={<div className="h-10 w-full" />}>
<GlobalSearch />
</Suspense>
</div>
</header>
);
}
56 changes: 9 additions & 47 deletions src/app/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<main className="grid md:grid-cols-4 gap-4">
<OverviewCard entity={entity} />

<SectionHeader>
Connections to People, Organizations, and Related Resources
</SectionHeader>
<Cards.Creators entity={entity} />
<Cards.Contributors entity={entity} />
<Cards.RelatedIdentifiers entity={entity} />
<Cards.FundingReferences entity={entity} />
<Cards.Publisher entity={entity} />

<SectionHeader>Descriptive Metadata</SectionHeader>
<Cards.ResourceType entity={entity} />
<Cards.Subjects entity={entity} />
<Cards.Descriptions entity={entity} />
<Cards.Titles entity={entity} />
<Cards.Rights entity={entity} />
<Cards.Dates entity={entity} />
<Cards.PublicationYear entity={entity} />
<Cards.AlternateIdentifiers entity={entity} />
<Cards.Language entity={entity} />
<Cards.Sizes entity={entity} />
<Cards.Formats entity={entity} />
<Cards.Version entity={entity} />
<Cards.GeoLocation entity={entity} />
<Cards.RelatedItem entity={entity} />
</main>
);
redirect(`/org-repo/${id.toLowerCase()}?${urlSearchParams.toString()}`);
}
168 changes: 168 additions & 0 deletions src/app/dois/DoiFacetsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, DoiFacetValue[]>;
accordionFacetValues: Record<string, DoiFacetValue[]>;
accordionFacetLoading: Record<string, boolean>;
interactingFacetKey: string | null;
facetsLoading: boolean;
publicationYearData: Array<{ year: string; count: number }>;
resourceTypeFacetData: Array<{ type: string; count: number }>;
selectedPublicationYears: Set<string>;
selectedResourceTypes: Set<string>;
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 (
<aside className="order-1 h-fit gap-4 lg:order-2">
<Accordion
className="border bg-card px-4 py-2 shadow-sm"
value={props.accordionOpenKeys}
onValueChange={(next) => {
const openKeys = Array.isArray(next) ? next : [next].filter(Boolean);
props.onOpenChange(openKeys as string[]);
}}
multiple
>
{DOI_FACET_CONFIGS.map((config) => {
const selected = props.selectedFacetValues[config.key] || [];
const options = props.accordionFacetValues[config.key] || [];
const apiOptionIds = new Set(options.map((item) => getCompareValue(config, item)));
const missingSelected = selected.filter(
(item) => !apiOptionIds.has(getCompareValue(config, item)),
);
const displayOptions = [...options, ...missingSelected];
const isLoading = !!props.accordionFacetLoading[config.key];
const hasSelection = selected.length > 0;
const isInteracting = props.interactingFacetKey === config.key;

return (
<AccordionItem key={config.key} value={config.key}>
<AccordionHeader>
<AccordionTrigger className="w-full py-2">
<span className="flex w-full items-center gap-2">
{config.icon ? <span className="text-gray-600">{config.icon}</span> : null}
<span className="font-semibold">{config.label}</span>
{isLoading ? <Spinner className="size-3.5 stroke-datacite-blue-dark" /> : null}
{hasSelection ? (
<Badge className="pointer-events-none ml-auto h-5 shrink-0 bg-gray-200 px-1.5 text-xs font-bold leading-none text-gray-700">
{asNumber(selected.length)}
</Badge>
) : null}
</span>
</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>
<div className="max-h-[280px] space-y-0.5 overflow-y-auto pb-1">
{!isLoading && displayOptions.length === 0 ? (
<p className="py-1 text-xs text-muted-foreground">No options found.</p>
) : null}
{displayOptions.map((item) => {
const checked = selected.some(
(value) => getCompareValue(config, value) === getCompareValue(config, item),
);
const hasApiCount = apiOptionIds.has(getCompareValue(config, item));
const dimmed = isInteracting && hasSelection && !checked;

return (
<button
type="button"
key={item.id}
className={`grid w-full grid-cols-[16px_minmax(0,1fr)_max-content] items-center gap-2 rounded-sm px-1 py-1 text-left text-sm transition-opacity hover:bg-gray-50 ${
isLoading
? "pointer-events-none opacity-40"
: dimmed
? "opacity-60"
: "opacity-100"
}`}
onClick={() => {
props.onFacetToggle(config.key, item, !checked);
}}
>
<Checkbox checked={checked} className="pointer-events-none" />
<span className="truncate">{item.title}</span>
<span className="tabular-nums text-xs text-muted-foreground">
{hasApiCount ? asNumber(item.count) : ""}
</span>
</button>
);
})}
</div>
</AccordionPanel>
</AccordionItem>
);
})}
</Accordion>

<div className="mt-4 space-y-3">
<Card className="py-3">
<CardContent className="px-3">
<H3 className="mb-2 text-sm font-semibold text-datacite-dark-gray">Resource Types</H3>
{props.facetsLoading ? (
<div className="flex h-[220px] items-center justify-center">
<Spinner className="size-5 stroke-datacite-blue-dark" />
</div>
) : props.resourceTypeFacetData.length > 0 ? (
<ResourceTypesChart
data={props.resourceTypeFacetData}
compact
selectedTypes={props.selectedResourceTypes}
onTypeClick={props.onResourceTypeClick}
/>
) : (
<p className="text-xs text-muted-foreground">No data available.</p>
)}
</CardContent>
</Card>
<Card className="py-3">
<CardContent className="px-3">
<H3 className="mb-2 text-sm font-semibold text-datacite-dark-gray">Publication Year</H3>
{props.facetsLoading ? (
<div className="flex h-[160px] items-center justify-center">
<Spinner className="size-5 stroke-datacite-blue-dark" />
</div>
) : props.publicationYearData.length > 0 ? (
<div className="overflow-hidden">
<DoiRegistrationsChart
data={props.publicationYearData}
compact
selectedYears={props.selectedPublicationYears}
onYearClick={props.onPublicationYearClick}
/>
</div>
) : (
<p className="text-xs text-muted-foreground">No data available.</p>
)}
</CardContent>
</Card>
</div>
</aside>
);
}
53 changes: 53 additions & 0 deletions src/app/dois/DoiMetricsSummary.tsx
Original file line number Diff line number Diff line change
@@ -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 <Skeleton className="h-[1lh] w-10 rounded-sm align-middle" />;
}

return <>{asNumber(props.metric.value || 0)}</>;
}

export default function DoiMetricsSummary(props: Props) {
return (
<div className="text-datacite-dark-gray">
<div className={`${dmSans.className} text-xl font-bold leading-tight text-datacite-dark-gray`}>
{props.isLoadingRecords ? (
<Skeleton className="h-[1lh] w-36 align-middle" />
) : (
<p>
{asNumber(props.total)} results
</p>
)}
</div>
<div className="mt-1 flex items-center gap-4 text-sm font-bold leading-tight">
<span className="inline-flex items-center gap-1 align-middle">
<Quote className="size-3.5" />
<MetricValue metric={props.citationCount} />
</span>
<span className="inline-flex items-center gap-1 align-middle">
<Eye className="size-3.5" />
<MetricValue metric={props.viewCount} />
</span>
<span className="inline-flex items-center gap-1 align-middle">
<ArrowDownToLine className="size-3.5" />
<MetricValue metric={props.downloadCount} />
</span>
</div>
</div>
);
}
Loading