feat(dashboard): list sources with statistics

This commit is contained in:
2025-11-13 11:25:07 +02:00
parent 8cc40fde67
commit 6503980cbc
24 changed files with 1016 additions and 373 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ export const sourcesRouter = createTRPCRouter({
get: protectedProcedure.query(async ({ ctx }) => getSources(ctx.db)),
getById: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => {
return getSourceById(ctx.db, { ...input });
return getSourceById(ctx.db, input.id);
}),
update: protectedProcedure.input(updateSourceSchema).mutation(async ({ ctx, input }) => {
+1
View File
@@ -17,6 +17,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "^7.66.0",
"recharts": "^3.4.1",
"superjson": "^2.2.5",
"zod": "^4.1.12",
"zustand": "^5.0.8"
@@ -1,3 +1,9 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Articles | Basango Dashboard",
};
export default function Page() {
return (
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
@@ -1,14 +1,6 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@basango/ui/components/breadcrumb";
import { Separator } from "@basango/ui/components/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@basango/ui/components/sidebar";
import { SidebarInset, SidebarProvider } from "@basango/ui/components/sidebar";
import { PageHeader } from "@/components/shell/page-header";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { HydrateClient } from "@/trpc/server";
@@ -19,23 +11,7 @@ export default async function Layout({ children }: { children: React.ReactNode }
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">Building Your Application</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<PageHeader />
{children}
</SidebarInset>
</SidebarProvider>
@@ -1,12 +1,26 @@
export default function Page() {
import { Metadata } from "next";
import { PageLayout } from "@/components/shell/page-layout";
import { SourceCard } from "@/components/source-card";
import { batchPrefetch, getQueryClient, trpc } from "@/trpc/server";
export const metadata: Metadata = {
title: "Sources | Basango Dashboard",
};
export default async function Page() {
const queryClient = getQueryClient();
batchPrefetch([trpc.sources.get.queryOptions()]);
const sources = await queryClient.fetchQuery(trpc.sources.get.queryOptions());
return (
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="bg-muted/50 aspect-video rounded-xl" />
<div className="bg-muted/50 aspect-video rounded-xl" />
<div className="bg-muted/50 aspect-video rounded-xl" />
<PageLayout leading="Manage your news sources" title="Sources">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{sources.map((source) => (
<SourceCard key={source.id} {...source} />
))}
</div>
<div className="bg-muted/50 min-h-screen flex-1 rounded-xl md:min-h-min" />
</div>
</PageLayout>
);
}
@@ -0,0 +1,54 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@basango/ui/components/breadcrumb";
import { Separator } from "@basango/ui/components/separator";
//import { LanguageSelector, ThemeSelector } from "@/components/ui/shared/settings";
import { SidebarTrigger } from "@basango/ui/components/sidebar";
export function PageHeader() {
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">Building Your Application</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
);
}
// <header className="border-b flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
// <div className="flex items-center gap-2 px-4">
// <SidebarTrigger className="-ml-1" />
// <Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
// <Breadcrumb>
// <BreadcrumbList>
// <BreadcrumbItem className="hidden md:block">
// <Button className="cursor-pointer" onClick={() => navigate(-1)} variant="ghost">
// <ArrowLeftIcon />
// <span>{t("ui.shared.shell.page_header.go_back")}</span>
// </Button>
// </BreadcrumbItem>
// </BreadcrumbList>
// </Breadcrumb>
// </div>
// <div className="flex items-center gap-2 px-4">
// <LanguageSelector />
// <ThemeSelector />
// </div>
// </header>
@@ -0,0 +1,31 @@
import React from "react";
interface PageProps {
children: React.ReactNode;
title?: string | React.ReactNode;
leading?: string | React.ReactNode;
}
export const PageLayout = (props: React.PropsWithChildren<PageProps>) => {
const { title, leading, children } = props;
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="container mx-auto">
{title && (
<div className="mb-8 flex items-center justify-between space-y-4">
<div>
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight text-balance flex items-center gap-2 justify-start">
{title}
</h1>
{leading && (
<p className="text-muted-foreground text-lg wrap-break-words">{leading}</p>
)}
</div>
</div>
)}
{children}
</div>
</div>
);
};
@@ -0,0 +1,97 @@
import { Skeleton } from "@basango/ui/components/skeleton";
type Props = {
dashboard?: boolean;
details?: boolean;
};
const DashboardSkeleton = () => {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="container mx-auto space-y-10">
<div className="space-y-2 mt-4">
<Skeleton className="h-8 w-[250px]" />
<Skeleton className="h-4 w-[550px]" />
</div>
<div className="space-y-2 mt-4">
<div className="grid grid-cols-3 gap-4">
<Skeleton className="h-[400px] w-full" />
<Skeleton className="h-[400px] w-full" />
<Skeleton className="h-[400px] w-full" />
</div>
</div>
<div className="space-y-2 mt-4">
<Skeleton className="h-[500px] w-full" />
</div>
<div className="mb-8 flex items-center justify-between space-y-6"></div>
</div>
</div>
);
};
const ListSkeleton = () => {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="container mx-auto space-y-10">
<div className="space-y-2 mt-4">
<Skeleton className="h-8 w-[250px]" />
<Skeleton className="h-4 w-[550px]" />
</div>
<div className="space-y-2 mt-4">
<div className="space-y-2 mt-4 flex justify-between">
<div className="flex justify-between gap-4">
<Skeleton className="h-9 w-[380px]" />
<Skeleton className="h-9 w-[150px]" />
</div>
<Skeleton className="h-9 w-[150px]" />
</div>
<Skeleton className="h-[500px] w-full" />
</div>
<div className="mb-8 flex items-center justify-between space-y-6"></div>
</div>
</div>
);
};
const DetailsSkeleton = () => {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="container mx-auto space-y-10">
<div className="space-y-2 mt-4">
<Skeleton className="h-8 w-[250px]" />
<Skeleton className="h-4 w-[550px]" />
</div>
<div className="space-y-2 mt-4">
<div className="space-y-2 mt-4 flex justify-between">
<div className="flex justify-between gap-4">
<Skeleton className="h-9 w-[380px]" />
</div>
<div className="flex justify-between gap-2">
<Skeleton className="h-9 w-[150px]" />
<Skeleton className="h-9 w-[150px]" />
<Skeleton className="h-9 w-[150px]" />
</div>
</div>
<Skeleton className="h-1 w-full mt-4" />
<Skeleton className="h-[100px] w-[700px] my-8" />
<div className="space-y-4">
{Array.from({ length: 6 }).map((_, index) => (
<div className="grid grid-cols-3 gap-4" key={index}>
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
))}
</div>
</div>
<div className="mb-8 flex items-center justify-between space-y-6"></div>
</div>
</div>
);
};
export const PageSkeleton = (props: Props) => {
if (props.dashboard) return <DashboardSkeleton />;
if (props.details) return <DetailsSkeleton />;
return <ListSkeleton />;
};
@@ -17,7 +17,7 @@ import {
} from "@basango/ui/components/sidebar";
import { ChevronRight, type LucideIcon } from "lucide-react";
export function NavMain({
export function AppSidebarContent({
items,
}: {
items: {
@@ -0,0 +1,26 @@
"use client";
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@basango/ui/components/sidebar";
export function AppSidebarInfo() {
const version = process.env.NEXT_PUBLIC_VERSION || "0.0.0";
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
size="lg"
>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<img alt="Logo" className="size-8 rounded-lg object-cover" src="/logo.svg" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Basango</span>
<span className="truncate text-xs">v{version}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}
@@ -1,6 +1,6 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@basango/ui/components/avatar";
import { Avatar, AvatarFallback } from "@basango/ui/components/avatar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,15 +18,7 @@ import {
} from "@basango/ui/components/sidebar";
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from "lucide-react";
export function NavUser({
user,
}: {
user: {
name: string;
email: string;
avatar: string;
};
}) {
export function AppSidebarUser() {
const { isMobile } = useSidebar();
return (
@@ -39,12 +31,11 @@ export function NavUser({
size="lg"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage alt={user.name} src={user.avatar} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">BN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-medium">Bernard Ng</span>
<span className="truncate text-xs">bernard.ng@example.com</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
@@ -58,12 +49,11 @@ export function NavUser({
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage alt={user.name} src={user.avatar} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">BN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-medium">Bernard Ng</span>
<span className="truncate text-xs">bernard.ng@example.com</span>
</div>
</div>
</DropdownMenuLabel>
@@ -7,166 +7,45 @@ import {
SidebarHeader,
SidebarRail,
} from "@basango/ui/components/sidebar";
import {
AudioWaveform,
BookOpen,
Bot,
Command,
Frame,
GalleryVerticalEnd,
MapIcon,
PieChart,
Settings2,
SquareTerminal,
} from "lucide-react";
import { SquareTerminal } from "lucide-react";
import * as React from "react";
import { NavMain } from "./nav-main";
import { NavProjects } from "./nav-projects";
import { NavUser } from "./nav-user";
import { TeamSwitcher } from "./team-switcher";
import { AppSidebarContent } from "./app-sidebar-content";
import { AppSidebarInfo } from "./app-sidebar-info";
import { AppSidebarUser } from "./app-sidebar-user";
const data = {
navMain: [
main: [
{
icon: SquareTerminal,
isActive: true,
items: [
{
title: "History",
url: "#",
title: "Sources",
url: "/sources",
},
{
title: "Starred",
url: "#",
},
{
title: "Settings",
url: "#",
title: "Articles",
url: "/articles",
},
],
title: "Playground",
url: "#",
},
{
icon: Bot,
items: [
{
title: "Genesis",
url: "#",
},
{
title: "Explorer",
url: "#",
},
{
title: "Quantum",
url: "#",
},
],
title: "Models",
url: "#",
},
{
icon: BookOpen,
items: [
{
title: "Introduction",
url: "#",
},
{
title: "Get Started",
url: "#",
},
{
title: "Tutorials",
url: "#",
},
{
title: "Changelog",
url: "#",
},
],
title: "Documentation",
url: "#",
},
{
icon: Settings2,
items: [
{
title: "General",
url: "#",
},
{
title: "Team",
url: "#",
},
{
title: "Billing",
url: "#",
},
{
title: "Limits",
url: "#",
},
],
title: "Settings",
title: "Dataset",
url: "#",
},
],
projects: [
{
icon: Frame,
name: "Design Engineering",
url: "#",
},
{
icon: PieChart,
name: "Sales & Marketing",
url: "#",
},
{
icon: MapIcon,
name: "Travel",
url: "#",
},
],
teams: [
{
logo: GalleryVerticalEnd,
name: "Acme Inc",
plan: "Enterprise",
},
{
logo: AudioWaveform,
name: "Acme Corp.",
plan: "Startup",
},
{
logo: Command,
name: "Evil Corp.",
plan: "Free",
},
],
user: {
avatar: "/avatars/shadcn.jpg",
email: "m@example.com",
name: "shadcn",
},
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<TeamSwitcher teams={data.teams} />
<AppSidebarInfo />
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavProjects projects={data.projects} />
<AppSidebarContent items={data.main} />
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />
<AppSidebarUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
@@ -1,82 +0,0 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@basango/ui/components/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@basango/ui/components/sidebar";
import { Folder, Forward, type LucideIcon, MoreHorizontal, Trash2 } from "lucide-react";
export function NavProjects({
projects,
}: {
projects: {
name: string;
url: string;
icon: LucideIcon;
}[];
}) {
const { isMobile } = useSidebar();
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
align={isMobile ? "end" : "start"}
className="w-48 rounded-lg"
side={isMobile ? "bottom" : "right"}
>
<DropdownMenuItem>
<Folder className="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward className="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontal className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
@@ -1,88 +0,0 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@basango/ui/components/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@basango/ui/components/sidebar";
import { ChevronsUpDown, Plus } from "lucide-react";
import * as React from "react";
export function TeamSwitcher({
teams,
}: {
teams: {
name: string;
logo: React.ElementType;
plan: string;
}[];
}) {
const { isMobile } = useSidebar();
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
if (!activeTeam) {
return null;
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
size="lg"
>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<activeTeam.logo className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeTeam.name}</span>
<span className="truncate text-xs">{activeTeam.plan}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-muted-foreground text-xs">Teams</DropdownMenuLabel>
{teams.map((team, index) => (
<DropdownMenuItem
className="gap-2 p-2"
key={team.name}
onClick={() => setActiveTeam(team)}
>
<div className="flex size-6 items-center justify-center rounded-md border">
<team.logo className="size-3.5 shrink-0" />
</div>
{team.name}
<DropdownMenuShortcut>{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 p-2">
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
<Plus className="size-4" />
</div>
<div className="text-muted-foreground font-medium">Add team</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
@@ -0,0 +1,191 @@
"use client";
import { Badge } from "@basango/ui/components/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@basango/ui/components/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@basango/ui/components/chart";
import { Separator } from "@basango/ui/components/separator";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
interface CredibilityMetric {
bias: string;
reliability: string;
transparency: string;
}
interface PublicationItem {
date: string;
count: number;
}
interface SourceCardProps {
id: string;
name: string;
displayName: string | null;
url: string;
description: string;
publicationGraph: {
items: PublicationItem[];
total: number;
};
credibility: CredibilityMetric;
}
const credibilityColors = {
high: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
low: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
};
function getCredibilityColor(value: string): string {
const lower = value.toLowerCase();
if (lower.includes("high") || lower.includes("strong") || lower.includes("good")) {
return credibilityColors.high;
}
if (lower.includes("medium") || lower.includes("moderate")) {
return credibilityColors.medium;
}
return credibilityColors.low;
}
const chartConfig = {
count: {
color: "var(--chart-2)",
label: "Articles",
},
views: {
label: "Articles",
},
} satisfies ChartConfig;
export function SourceCard({
id,
name,
displayName,
url,
description,
publicationGraph,
credibility,
}: SourceCardProps) {
const chartData = publicationGraph.items;
return (
<Card className="w-full max-w-6xl border-border">
<CardHeader className="border-b">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2">
<CardTitle>
<a
aria-label="Visit source website"
href={url}
rel="noopener noreferrer"
target="_blank"
>
{displayName || name}
</a>
</CardTitle>
</div>
<CardDescription className="text-base">{description}</CardDescription>
<p className="text-xs text-muted-foreground">ID: {id}</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
<BarChart
accessibilityLayer
data={chartData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
});
}}
tickLine={false}
tickMargin={8}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
});
}}
nameKey="views"
/>
}
/>
<Bar dataKey="count" fill={`var(--color-2)`} />
</BarChart>
</ChartContainer>
<Separator />
<div className="space-y-2">
<h3 className="font-semibold">Credibility Metrics</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Bias</p>
<Badge className={`${getCredibilityColor(credibility.bias)} border-0`}>
{credibility.bias}
</Badge>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Reliability</p>
<Badge className={`${getCredibilityColor(credibility.reliability)} border-0`}>
{credibility.reliability}
</Badge>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Transparency</p>
<Badge className={`${getCredibilityColor(credibility.transparency)} border-0`}>
{credibility.transparency}
</Badge>
</div>
</div>
</div>
<Separator />
<div className="space-y-2">
<h3 className="font-semibold">Summary</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Total Publications</p>
<p className="text-2xl font-bold">{publicationGraph.total}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Timeline Entries</p>
<p className="text-2xl font-bold">{publicationGraph.items.length}</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
+2 -2
View File
@@ -19,10 +19,10 @@ export const trpc = createTRPCOptionsProxy<AppRouter>({
links: [
httpBatchLink({
async headers() {
const token = window.localStorage.getItem("auth_token");
//const token = window.localStorage.getItem("auth_token");
return {
Authorization: `Bearer ${token}`,
//Authorization: `Bearer ${token}`,
// "x-user-country": await getCountryCode(),
// "x-user-locale": await getLocale(),
// "x-user-timezone": await getTimezone(),
+4 -1
View File
@@ -58,7 +58,7 @@
"rules": {
"a11y": {
"noSvgWithoutTitle": "off",
"useFocusableInteractive": "warn",
"useFocusableInteractive": "off",
"useSemanticElements": "off",
"useValidAnchor": "off"
},
@@ -67,6 +67,9 @@
"useImportExtensions": "off"
},
"recommended": true,
"security": {
"noDangerouslySetInnerHtml": "off"
},
"style": {
"noNonNullAssertion": "off",
"useImportType": "off"
+73
View File
@@ -79,6 +79,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "^7.66.0",
"recharts": "^3.4.1",
"superjson": "^2.2.5",
"zod": "^4.1.12",
"zustand": "^5.0.8",
@@ -133,6 +134,7 @@
"@basango/encryption": "workspace:*",
"@basango/logger": "workspace:*",
"@date-fns/utc": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.7",
"mysql2": "^3.15.3",
"pg": "^8.16.3",
@@ -186,6 +188,7 @@
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"recharts": "^3.4.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.6",
@@ -896,6 +899,8 @@
"@react-navigation/routers": ["@react-navigation/routers@7.5.1", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-pxipMW/iEBSUrjxz2cDD7fNwkqR4xoi0E/PcfTQGCcdJwLoaxzab5kSadBLj1MTJyT0YRrOXL9umHpXtp+Dv4w=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.10.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.2.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.1", "", { "os": "android", "cpu": "arm" }, "sha512-bxZtughE4VNVJlL1RdoSE545kc4JxL7op57KKoi59/gwuU5rV6jLWFXXc8jwgFoT6vtj+ZjO+Z2C5nrY0Cl6wA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.1", "", { "os": "android", "cpu": "arm64" }, "sha512-44a1hreb02cAAfAKmZfXVercPFaDjqXCK+iKeVOlJ9ltvnO6QqsBHgKVPTu+MJHSLLeMEUbeG2qiDYgbFPU48g=="],
@@ -1032,6 +1037,24 @@
"@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -1068,6 +1091,8 @@
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/yargs": ["@types/yargs@17.0.34", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
@@ -1332,6 +1357,28 @@
"cz-conventional-changelog": ["cz-conventional-changelog@3.3.0", "", { "dependencies": { "chalk": "^2.4.1", "commitizen": "^4.0.3", "conventional-commit-types": "^3.0.0", "lodash.map": "^4.5.1", "longest": "^2.0.1", "word-wrap": "^1.0.3" }, "optionalDependencies": { "@commitlint/load": ">6.1.1" } }, "sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"dargs": ["dargs@8.1.0", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
@@ -1342,6 +1389,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
"dedent": ["dedent@0.7.0", "", {}, "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA=="],
@@ -1422,6 +1471,8 @@
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-toolkit": ["es-toolkit@1.41.0", "", {}, "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
@@ -1446,6 +1497,8 @@
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"exec-async": ["exec-async@2.2.0", "", {}, "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw=="],
@@ -1636,6 +1689,8 @@
"image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
@@ -1656,6 +1711,8 @@
"international-types": ["international-types@0.8.1", "", {}, "sha512-tajBCAHo4I0LIFlmQ9ZWfjMWVyRffzuvfbXCd6ssFt5u1Zw15DN0UBpVTItXdNa1ls+cpQt3Yw8+TxsfGF8JcA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
"ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
@@ -2166,6 +2223,8 @@
"react-native-worklets": ["react-native-worklets@0.5.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-classes": "^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", "@babel/plugin-transform-optional-chaining": "^7.0.0-0", "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
@@ -2178,10 +2237,16 @@
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"recharts": ["recharts@3.4.1", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-35kYg6JoOgwq8sE4rhYkVWwa6aAIgOtT+Ob0gitnShjwUwZmhrmy7Jco/5kJNF4PnLXgt9Hwq+geEMS+WrjU1g=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
"regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="],
@@ -2204,6 +2269,8 @@
"requireg": ["requireg@0.2.2", "", { "dependencies": { "nested-error-stacks": "~2.0.1", "rc": "~1.2.7", "resolve": "~1.7.1" } }, "sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-dir": ["resolve-dir@1.0.1", "", { "dependencies": { "expand-tilde": "^2.0.0", "global-modules": "^1.0.0" } }, "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg=="],
@@ -2402,6 +2469,8 @@
"tiktoken": ["tiktoken@1.0.22", "", {}, "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
@@ -2512,6 +2581,8 @@
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@7.2.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ=="],
"vitest": ["vitest@4.0.8", "", { "dependencies": { "@vitest/expect": "4.0.8", "@vitest/mocker": "4.0.8", "@vitest/pretty-format": "4.0.8", "@vitest/runner": "4.0.8", "@vitest/snapshot": "4.0.8", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.8", "@vitest/browser-preview": "4.0.8", "@vitest/browser-webdriverio": "4.0.8", "@vitest/ui": "4.0.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg=="],
@@ -2604,6 +2675,8 @@
"@basango/dashboard/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"@basango/db/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"@commitlint/format/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@commitlint/load/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+1
View File
@@ -3,6 +3,7 @@
"@basango/encryption": "workspace:*",
"@basango/logger": "workspace:*",
"@date-fns/utc": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.7",
"mysql2": "^3.15.3",
"pg": "^8.16.3",
+12 -1
View File
@@ -8,7 +8,18 @@ export const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/";
* Number of days to include in the publication graph for sources.
* This defines the time range for which publication data is aggregated and displayed.
*/
export const PUBLICATION_GRAPH_DAYS = 180;
export const PUBLICATION_GRAPH_DAYS = 60;
/**
* Maximum number of category shares to return for a source.
* This limits the number of categories displayed in the category share breakdown.
*/
export const CATEGORY_SHARES_LIMIT = 10;
/**
* The default timezone
*/
export const TIMEZONE = "Africa/Lubumbashi";
/**
* Default pagination settings.
+112 -10
View File
@@ -1,12 +1,24 @@
import { eq } from "drizzle-orm";
import { endOfDay, startOfDay, subDays } from "date-fns";
import { eq, sql } from "drizzle-orm";
import { v7 as uuidV7 } from "uuid";
import { Database } from "@/client";
import { CATEGORY_SHARES_LIMIT, PUBLICATION_GRAPH_DAYS, TIMEZONE } from "@/constants";
import { NotFoundError } from "@/errors";
import { Credibility, source } from "@/schema";
import { Credibility, article, source } from "@/schema";
export async function getSources(db: Database) {
return db.query.source.findMany();
const rows = await db.query.source.findMany();
const data = await Promise.all(
rows.map(async (it) => ({
...it,
categoryShares: await getCategoryShares(db, it.id),
publicationGraph: await getPublicationGraph(db, it.id),
})),
);
return data;
}
export type CreateSourceParams = {
@@ -69,10 +81,20 @@ export async function getSourceByName(db: Database, name: string) {
});
}
export async function getById(db: Database, id: string) {
return db.query.source.findFirst({
export async function getSourceById(db: Database, id: string) {
const item = db.query.source.findFirst({
where: eq(source.id, id),
});
if (item === undefined) {
throw new NotFoundError("Source not found");
}
return {
...item,
categoryShares: await getCategoryShares(db, id),
publicationGraph: await getPublicationGraph(db, id),
};
}
export async function getSourceIdByName(db: Database, name: string): Promise<string> {
@@ -84,7 +106,7 @@ export async function getSourceIdByName(db: Database, name: string): Promise<str
});
if (!result) {
throw new NotFoundError(`Source with name "${name}" not found`);
throw new NotFoundError("Source not found");
}
return result.id;
@@ -94,8 +116,88 @@ export type GetSourceByIdParams = {
id: string;
};
export async function getSourceById(db: Database, params: GetSourceByIdParams) {
return db.query.source.findFirst({
where: eq(source.id, params.id),
});
export type PublicationEntry = {
date: string;
count: number;
};
export type PublicationGraph = {
items: PublicationEntry[];
total: number;
};
export type CategoryShare = {
category: string;
count: number;
percentage: number;
};
export type CategoryShares = {
items: CategoryShare[];
total: number;
};
export async function getPublicationGraph(
db: Database,
id: string,
days: number = PUBLICATION_GRAPH_DAYS,
): Promise<PublicationGraph> {
const endDate = endOfDay(new Date());
const startDate = startOfDay(subDays(endDate, days - 1));
const data = await db.execute<{ date: string; count: number }>(sql`
WITH bounds AS (
SELECT
${startDate}::timestamptz AS start_ts,
${endDate}::timestamptz AS end_ts
),
series AS (
SELECT (gs)::date AS d
FROM bounds b,
LATERAL generate_series(
date_trunc('day', timezone(${TIMEZONE}, b.start_ts)),
date_trunc('day', timezone(${TIMEZONE}, b.end_ts)),
INTERVAL '1 day'
) AS gs
),
counts AS (
SELECT
a.published_at::date AS d,
COUNT(*)::int AS c
FROM article a, bounds b
WHERE a.source_id = ${id}::uuid
AND a.published_at >= timezone(${TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${TIMEZONE}, b.end_ts)
GROUP BY 1
)
SELECT
to_char(s.d, 'YYYY-MM-DD') AS date,
COALESCE(c.c, 0) AS count
FROM series s
LEFT JOIN counts c USING (d)
ORDER BY s.d ASC
`);
return { items: data.rows, total: data.rows.length };
}
async function getCategoryShares(db: Database, id: string): Promise<CategoryShares> {
const data = await db.execute<CategoryShare>(sql`
SELECT
cat AS category,
COUNT(*)::int AS count,
ROUND((COUNT(*)::numeric / SUM(COUNT(*)) OVER ()) * 100, 2) AS percentage
FROM (
SELECT NULLIF(BTRIM(c), '') AS cat
FROM ${article}
CROSS JOIN LATERAL UNNEST(COALESCE(${article.categories}, ARRAY[]::text[])) AS c
WHERE ${article.sourceId} = ${id}
) t
WHERE cat IS NOT NULL
GROUP BY cat
ORDER BY count DESC
LIMIT ${CATEGORY_SHARES_LIMIT}
`);
return { items: data.rows, total: data.rowCount ?? 0 };
}
+1
View File
@@ -24,6 +24,7 @@
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"recharts": "^3.4.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.6",
+38
View File
@@ -0,0 +1,38 @@
import { cn } from "@basango/ui/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
},
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp className={cn(badgeVariants({ variant }), className)} data-slot="badge" {...props} />
);
}
export { Badge, badgeVariants };
+319
View File
@@ -0,0 +1,319 @@
import { cn } from "@basango/ui/lib/utils";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { dark: ".dark", light: "" } 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
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,
)}
data-chart={chartId}
data-slot="chart"
{...props}
>
<ChartStyle config={config} id={chartId} />
<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;
}) {
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.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
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",
)}
key={item.dataKey}
>
{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",
"my-0.5": nestLabel && indicator === "dashed",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"w-1": indicator === "line",
},
)}
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"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
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.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
key={item.value}
>
{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,
};