feat(dashboard): source overview
This commit is contained in:
@@ -11,7 +11,6 @@
|
||||
],
|
||||
"allowMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||
"exposeHeaders": ["Content-Length"],
|
||||
"maxAge": 86400,
|
||||
"origin": "%env(BASANGO_API_ALLOWED_ORIGINS)%"
|
||||
"maxAge": 86400
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ const ServerConfigurationSchema = z.object({
|
||||
allowMethods: z.array(z.string()).optional(),
|
||||
exposeHeaders: z.array(z.string()).optional(),
|
||||
maxAge: z.number().int().min(0).optional(),
|
||||
origin: z.string(), //z.array(z.string()).default([]),
|
||||
origin: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default(["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"]),
|
||||
}),
|
||||
server: z.object({
|
||||
host: z.string().default("localhost"),
|
||||
|
||||
@@ -22,7 +22,7 @@ app.use(
|
||||
allowMethods: config.cors.allowMethods,
|
||||
exposeHeaders: config.cors.exposeHeaders,
|
||||
maxAge: config.cors.maxAge,
|
||||
origin: config.cors.origin,
|
||||
origin: ["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -55,6 +55,43 @@ export const getSourceSchema = z.object({
|
||||
id: idSchema,
|
||||
});
|
||||
|
||||
export const getSourcePublicationGraphSchema = z.object({
|
||||
days: z
|
||||
.number()
|
||||
.optional()
|
||||
.openapi({
|
||||
default: 60,
|
||||
description: "",
|
||||
example: 60,
|
||||
})
|
||||
.openapi({
|
||||
description: "The number of days to include in the publication graph.",
|
||||
}),
|
||||
id: idSchema,
|
||||
range: z
|
||||
.object({
|
||||
from: z.date().openapi({
|
||||
description: "The start date of the range.",
|
||||
}),
|
||||
to: z.date().openapi({
|
||||
description: "The end date of the range.",
|
||||
}),
|
||||
})
|
||||
.optional()
|
||||
.openapi({
|
||||
description: "The date range for the publication graph.",
|
||||
}),
|
||||
});
|
||||
|
||||
export const getSourceCategorySharesSchema = z.object({
|
||||
id: idSchema,
|
||||
limit: z.number().int().min(1).max(100).optional().openapi({
|
||||
default: 10,
|
||||
description: "The maximum number of categories to return.",
|
||||
example: 10,
|
||||
}),
|
||||
});
|
||||
|
||||
export const updateSourceSchema = z.object({
|
||||
credibility: credibilitySchema.optional(),
|
||||
description: createSourceSchema.shape.description,
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
updateSource,
|
||||
} from "@basango/db/queries";
|
||||
|
||||
import { createSourceSchema, getSourceSchema, updateSourceSchema } from "#api/schemas/sources";
|
||||
import {
|
||||
createSourceSchema,
|
||||
getSourceCategorySharesSchema,
|
||||
getSourcePublicationGraphSchema,
|
||||
getSourceSchema,
|
||||
updateSourceSchema,
|
||||
} from "#api/schemas/sources";
|
||||
import { createTRPCRouter, protectedProcedure } from "#api/trpc/init";
|
||||
|
||||
export const sourcesRouter = createTRPCRouter({
|
||||
@@ -21,13 +27,17 @@ export const sourcesRouter = createTRPCRouter({
|
||||
return getSourceById(ctx.db, input.id);
|
||||
}),
|
||||
|
||||
getCategoryShares: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => {
|
||||
return getSourceCategoryShares(ctx.db, input.id);
|
||||
}),
|
||||
getCategoryShares: protectedProcedure
|
||||
.input(getSourceCategorySharesSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getSourceCategoryShares(ctx.db, input);
|
||||
}),
|
||||
|
||||
getPublicationGraph: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => {
|
||||
return getSourcePublicationGraph(ctx.db, input.id);
|
||||
}),
|
||||
getPublicationGraph: protectedProcedure
|
||||
.input(getSourcePublicationGraphSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getSourcePublicationGraph(ctx.db, input);
|
||||
}),
|
||||
|
||||
update: protectedProcedure.input(updateSourceSchema).mutation(async ({ ctx, input }) => {
|
||||
return updateSource(ctx.db, input);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
-8
@@ -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}
|
||||
/>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user