include pub stats

This commit is contained in:
2025-09-22 11:18:41 +02:00
parent 8d18a6c02e
commit 0fe7e0a9b5
7 changed files with 1121 additions and 144 deletions

View File

@@ -6,6 +6,7 @@ import fs from "fs";
import path from "path";
import { TrackedLink } from "@/components/util-tracked-link";
import { Metadata } from "next";
import { PublicationStats } from "@/components/publication-stats";
export const metadata: Metadata = {
alternates: {
@@ -59,8 +60,15 @@ export default function PublicationsPage() {
</TrackedLink>
))}
</div>
<hr/>
<div className="space-y-8"> {/* Increased spacing between year sections */}
<div className="container mx-auto p-0">
<PublicationStats />
</div>
<hr />
<h2 className="font-bold text-2xl">References Statistics</h2>
<div className="space-y-8">
{years.map((year) => (
<div key={year} className="flex flex-col md:flex-row md:space-x-8">
<div className="md:w-16 flex-shrink-0">
@@ -68,7 +76,7 @@ export default function PublicationsPage() {
{year}
</h2>
</div>
<div className="flex-grow space-y-4 pt-2 md:pt-0"> {/* Main content column */}
<div className="flex-grow space-y-4 pt-2 md:pt-0">
{publicationsByYear[year].map((pub) => (
<PublicationCard
key={pub.key}

View File

@@ -111,7 +111,6 @@ export function PublicationCard({
onError={() => setImageError(true)}
sizes="96px"
loading="lazy"
objectFit="cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center rounded-md bg-muted/20">

View File

@@ -0,0 +1,47 @@
// app/components/publication-chart-client.tsx
"use client";
import {
Bar,
BarChart,
ResponsiveContainer,
XAxis,
LabelList,
} from "recharts";
interface ChartData {
year: number;
count: number;
}
export function PublicationChartClient({ data }: { data: ChartData[] }) {
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 10 }}>
<XAxis
dataKey="year"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
{/* Y-axis is hidden as per the design */}
<Bar
dataKey="count"
fill="hsl(var(--foreground))"
radius={[4, 4, 0, 0]}
height={20}
>
<LabelList
dataKey="count"
position="top"
offset={6}
fontSize={12}
fill="hsl(var(--foreground))"
/>
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,117 @@
// app/components/publication-stats.tsx
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import * as cheerio from "cheerio";
import { PublicationChartClient } from "./publication-chart"; // <-- IMPORT THE NEW CLIENT COMPONENT
// Define the structure for our scraped data (can be shared or defined here)
interface ScholarStats {
citations: { all: number };
hIndex: { all: number };
i10Index: { all: number };
citationsPerYear: { year: number; count: number }[];
}
// Function to fetch and parse data from Google Scholar (no changes needed here)
async function getScholarStats(): Promise<ScholarStats> {
const url = "https://scholar.google.de/citations?user=NODAd94AAAAJ&hl=en";
try {
const response = await fetch(url, {
next: { revalidate: 86400 }, // Revalidate once a day
});
if (!response.ok) throw new Error("Failed to fetch Google Scholar page");
const html = await response.text();
const $ = cheerio.load(html);
const citations = parseInt($("#gsc_rsb_st > tbody > tr:nth-child(1) > td:nth-child(2)").text() || "0");
const hIndex = parseInt($("#gsc_rsb_st > tbody > tr:nth-child(2) > td:nth-child(2)").text() || "0");
const i10Index = parseInt($("#gsc_rsb_st > tbody > tr:nth-child(3) > td:nth-child(2)").text() || "0");
const citationsPerYear: { year: number; count: number }[] = [];
const yearElements = $(".gsc_g_t");
const citationElements = $(".gsc_g_a");
yearElements.each((index, element) => {
const year = parseInt($(element).text());
const count = parseInt($(citationElements[index]).find('.gsc_g_al').text() || "0");
if (!isNaN(year) && !isNaN(count)) {
citationsPerYear.push({ year, count });
}
});
return {
citations: { all: citations },
hIndex: { all: hIndex },
i10Index: { all: i10Index },
citationsPerYear: citationsPerYear,
};
} catch (error) {
console.error("Error scraping Google Scholar:", error);
return { citations: { all: 0 }, hIndex: { all: 0 }, i10Index: { all: 0 }, citationsPerYear: [] };
}
}
// The main component that renders the statistics
export async function PublicationStats() {
const stats = await getScholarStats();
const currentYear = new Date().getFullYear();
const lastFiveYearsData = stats.citationsPerYear
.filter((entry) => entry.year >= currentYear - 4)
.sort((a, b) => a.year - b.year);
return (
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Publication Statistics</h2>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
<Card>
<CardContent className="px-4 py-0 flex flex-col gap-1">
<p className="text-sm font-medium text-muted-foreground">
Citations
</p>
<p className="text-2xl font-bold">{stats.citations.all}</p>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-4">
<Card>
<CardContent className="px-4 py-0 flex flex-col gap-1">
<p className="text-sm font-medium text-muted-foreground">
h-index
</p>
<p className="text-2xl font-bold">{stats.hIndex.all}</p>
</CardContent>
</Card>
<Card>
<CardContent className="px-4 py-0 flex flex-col gap-1">
<p className="text-sm font-medium text-muted-foreground">
i10-index
</p>
<p className="text-2xl font-bold">{stats.i10Index.all}</p>
</CardContent>
</Card>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">
Citations per Year
</CardTitle>
</CardHeader>
<CardContent className="h-32">
<PublicationChartClient data={lastFiveYearsData} />
</CardContent>
</Card>
</div>
</div>
);
}

362
components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,362 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import { cn } from "@/lib/utils"
import { Props } from "recharts/types/component/DefaultTooltipContent";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
label?: string
payload?: Props<ValueType, NameType>['payload'];
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={`tooltip-item-${item.name}-${index}`}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean;
nameKey?: string;
payload?: Props<ValueType, NameType>['payload'];
verticalAlign?: RechartsPrimitive.LegendProps["verticalAlign"];
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={`tooltip-item-${item.name}`}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -15,19 +15,21 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@retorquere/bibtex-parser": "^9.0.21",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/typography": "^0.5.18",
"cheerio": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"gray-matter": "^4.0.3",
"lucide-react": "^0.544.0",
"mdx-bundler": "^10.1.1",
"motion": "^12.23.14",
"motion": "^12.23.16",
"next": "15.5.3",
"next-themes": "^0.4.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"recharts": "3.2.1",
"rehype-pretty-code": "^0.14.1",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
@@ -36,7 +38,7 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"rough-notation": "^0.5.1",
"shiki": "^3.12.2",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5"
@@ -47,7 +49,7 @@
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"eslint": "^9.35.0",
"eslint": "^9.36.0",
"eslint-config-next": "15.5.3",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.3.8",

714
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff