feat(dashboard): source overview

This commit is contained in:
2025-11-15 01:06:03 +02:00
parent 7b01f67358
commit 1a0abbabaf
14 changed files with 371 additions and 30 deletions
@@ -0,0 +1,51 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@basango/ui/components/tabs";
import { Metadata } from "next";
import { SourceCategorySharesChart } from "#dashboard/components/charts/source-category-shares-chart";
import { SourcePublicationgGraphChart } from "#dashboard/components/charts/source-publication-graph-chart";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { HydrateClient, batchPrefetch, getQueryClient, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = {
title: "Source Details | Basango Dashboard",
};
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const queryClient = getQueryClient();
batchPrefetch([
trpc.sources.getById.queryOptions({ id }),
trpc.sources.getCategoryShares.queryOptions({ id }),
trpc.sources.getPublicationGraph.queryOptions({ id }),
]);
const source = await queryClient.fetchQuery(trpc.sources.getById.queryOptions({ id }));
return (
<HydrateClient>
<PageLayout leading={source.description ?? "No description available"} title={source.name}>
<Tabs className="space-y-4" defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="articles">Articles</TabsTrigger>
</TabsList>
<TabsContent className="space-y-4" value="overview">
<SourceCategorySharesChart sourceId={source.id} />
<SourcePublicationgGraphChart sourceId={source.id} />
</TabsContent>
<TabsContent value="articles">
<div className="flex flex-1 flex-col gap-4">
<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" />
</div>
<div className="bg-muted/50 min-h-screen flex-1 rounded-xl md:min-h-min" />
</div>
</TabsContent>
</Tabs>
</PageLayout>
</HydrateClient>
);
}
@@ -1,8 +1,9 @@
import { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { Metadata } from "next";
import Link from "next/link";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { SourceCard } from "#dashboard/components/source-card";
import { SourceCard } from "#dashboard/components/widgets/source-card";
import { HydrateClient, getQueryClient, prefetch, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = {
@@ -22,7 +23,9 @@ export default async function Page() {
<PageLayout leading="Manage your news sources" title="Sources">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{sources.map((source: SourceDetails) => (
<SourceCard key={source.id} source={source} />
<Link href={`/sources/${source.id}`} key={source.id}>
<SourceCard source={source} />
</Link>
))}
</div>
</PageLayout>
@@ -0,0 +1,102 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@basango/ui/components/card";
import { ToggleGroup, ToggleGroupItem } from "@basango/ui/components/toggle-group";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Bar, BarChart, Legend, ResponsiveContainer, XAxis, YAxis } from "recharts";
import { useTRPC } from "#dashboard/trpc/client";
import { getColorFromName } from "#dashboard/utils/categories";
type Props = {
sourceId: string;
};
export function SourceCategorySharesChart({ sourceId }: Props) {
const trpc = useTRPC();
const [limit, setLimit] = useState(10);
const { data } = useQuery(
trpc.sources.getCategoryShares.queryOptions({
id: sourceId,
limit: limit,
}),
);
const items = data?.items ?? [];
const chartData = [
{
name: "Total",
...Object.fromEntries(items.map((item) => [item.category, item.count])),
},
];
const barData = items.map((item) => ({
fill: getColorFromName(item.category),
name: item.category,
}));
return (
<Card className="pt-0">
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<div className="grid flex-1 gap-1">
<CardTitle>Category Shares</CardTitle>
<CardDescription>showing top {limit} categories for this source</CardDescription>
</div>
<ToggleGroup
className="*:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
onValueChange={(v) => setLimit(Number(v))}
type="single"
value={String(limit)}
variant="outline"
>
<ToggleGroupItem value="10">Top 10</ToggleGroupItem>
<ToggleGroupItem value="20">Top 20</ToggleGroupItem>
<ToggleGroupItem value="50">Top 50</ToggleGroupItem>
</ToggleGroup>
</CardHeader>
<CardContent>
<div className="-ml-1 h-20">
<ResponsiveContainer height="100%" width="100%">
<BarChart
data={chartData}
layout="vertical"
margin={{ bottom: 0, left: 0, right: 0, top: 0 }}
>
<YAxis
axisLine={false}
dataKey="name"
fontSize={12}
hide
scale="band"
type="category"
/>
<XAxis axisLine={false} fontSize={12} hide tickLine={false} type="number" />
<Legend align="left" iconSize={8} iconType="circle" />
{barData.map((entry, index) => (
<Bar
barSize={16}
className="transition-all delay-75"
dataKey={entry.name}
fill={entry.fill}
key={`bar-${index}`}
radius={
index === 0 ? [4, 0, 0, 4] : index === barData.length - 1 ? [0, 4, 4, 0] : 0
}
stackId="category"
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,109 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@basango/ui/components/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@basango/ui/components/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@basango/ui/components/select";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { useTRPC } from "#dashboard/trpc/client";
import { formatDate } from "#dashboard/utils/utils";
const chartConfig = {
count: {
color: "var(--chart-2)",
label: "Articles",
},
views: {
label: "Articles",
},
} satisfies ChartConfig;
type Props = {
sourceId: string;
};
export function SourcePublicationgGraphChart({ sourceId }: Props) {
const trpc = useTRPC();
const [timeRange, setTimeRange] = React.useState("30");
const { data } = useQuery(
trpc.sources.getPublicationGraph.queryOptions({
days: Number(timeRange),
id: sourceId,
}),
);
return (
<Card className="pt-0">
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<div className="grid flex-1 gap-1">
<CardTitle>Publication Graph</CardTitle>
<CardDescription>
Showing total crawled articles for the last {timeRange} days
</CardDescription>
</div>
<Select onValueChange={setTimeRange} value={timeRange}>
<SelectTrigger
aria-label="Select a value"
className="hidden w-40 rounded-lg sm:ml-auto sm:flex"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem className="rounded-lg" value="7">
Last 7 days
</SelectItem>
<SelectItem className="rounded-lg" value="30">
Last 30 days
</SelectItem>
<SelectItem className="rounded-lg" value="90">
Last 3 months
</SelectItem>
<SelectItem className="rounded-lg" value="180">
Last 6 months
</SelectItem>
<SelectItem className="rounded-lg" value="365">
Last 12 months
</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
<BarChart accessibilityLayer data={data?.items}>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
minTickGap={32}
tickFormatter={(value) => formatDate(new Date(value).toISOString())}
tickLine={false}
tickMargin={8}
/>
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}
@@ -16,7 +16,7 @@ import {
} from "@basango/ui/components/chart";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { formatNumber } from "#dashboard/utils/utils";
import { formatDate, formatNumber } from "#dashboard/utils/utils";
const chartConfig = {
count: {
@@ -61,13 +61,7 @@ export function SourceCard({ source }: { source: SourceDetails }) {
axisLine={false}
dataKey="date"
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
});
}}
tickFormatter={(value) => formatDate(new Date(value).toISOString())}
tickLine={false}
tickMargin={8}
/>
+12
View File
@@ -54,3 +54,15 @@ export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptio
void queryClient.prefetchQuery(queryOptions);
}
}
export function batchPrefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptionsArray: T[]) {
const queryClient = getQueryClient();
for (const queryOptions of queryOptionsArray) {
if (queryOptions.queryKey[1]?.type === "infinite") {
void queryClient.prefetchInfiniteQuery(queryOptions as any);
} else {
void queryClient.prefetchQuery(queryOptions);
}
}
}