include pub stats
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
47
components/publication-chart.tsx
Normal file
47
components/publication-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
components/publication-stats.tsx
Normal file
117
components/publication-stats.tsx
Normal 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
362
components/ui/chart.tsx
Normal 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,
|
||||
}
|
||||
10
package.json
10
package.json
@@ -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
714
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user