feat(dashboard): add reports

This commit is contained in:
2025-11-18 13:48:34 +02:00
parent dbcd1d7485
commit 126505fc88
32 changed files with 553 additions and 170 deletions
@@ -2,18 +2,18 @@ import { Metadata } from "next";
import { ArticlesFeed } from "#dashboard/components/articles-feed";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { HydrateClient, batchPrefetch, trpc } from "#dashboard/trpc/server";
import { HydrateClient, prefetch, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = {
title: "Articles | Basango Dashboard",
};
export default function Page() {
batchPrefetch([trpc.articles.list.infiniteQueryOptions({ limit: 12 })]);
prefetch(trpc.articles.list.infiniteQueryOptions({ limit: 12 }));
return (
<HydrateClient>
<PageLayout leading="Track crawled content and trends" title="Articles">
<PageLayout title="Articles">
<ArticlesFeed />
</PageLayout>
</HydrateClient>
@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { PublicationGraphChart } from "#dashboard/components/charts/articles/publication-graph-chart";
import { SourceDistributionChart } from "#dashboard/components/charts/articles/source-distribution-chart";
import { DashboardOverviewCard } from "#dashboard/components/dashboard-overview-card";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { HydrateClient, batchPrefetch, trpc } from "#dashboard/trpc/server";
@@ -11,15 +12,17 @@ export const metadata: Metadata = {
export default async function Page() {
batchPrefetch([
trpc.reports.getDashboardOverview.queryOptions(),
trpc.articles.getPublications.queryOptions({}),
trpc.articles.getSourceDistribution.queryOptions({ limit: 8 }),
]);
return (
<HydrateClient>
<PageLayout leading="Keep track of article volume and source coverage" title="Dashboard">
<PageLayout title="Dashboard">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
<div className="lg:col-span-3">
<div className="lg:col-span-3 gap-4 flex flex-col">
<DashboardOverviewCard />
<PublicationGraphChart />
</div>
<SourceDistributionChart />
@@ -1,6 +1,5 @@
import { SidebarInset, SidebarProvider } from "@basango/ui/components/sidebar";
import { PageHeader } from "#dashboard/components/shell/page-header";
import { AppSidebar } from "#dashboard/components/sidebar/app-sidebar";
import { HydrateClient } from "#dashboard/trpc/server";
@@ -10,10 +9,7 @@ export default async function Layout({ children }: { children: React.ReactNode }
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<PageHeader />
{children}
</SidebarInset>
<SidebarInset>{children}</SidebarInset>
</SidebarProvider>
</HydrateClient>
);
@@ -27,7 +27,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
return (
<HydrateClient>
<PageLayout leading={source.description ?? "No description available"} title={source.name}>
<PageLayout title={source.name}>
<Tabs className="space-y-4" defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
@@ -23,8 +23,8 @@ export default async function Page() {
return (
<HydrateClient>
<PageLayout leading="Manage your news sources" title="Sources">
<div className="mb-6 flex justify-end">
<PageLayout title="Sources">
<div className="flex justify-end">
<Link href="?createSource=true">
<Button type="button">
<PlusIcon className="mr-2 size-4" />
@@ -10,9 +10,9 @@ export default function Page() {
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<div className="relative hidden lg:block">
<img
alt="verification placeholder"
alt="Login background"
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
src="https://images.pexels.com/photos/30690932/pexels-photo-30690932.jpeg"
/>
@@ -10,7 +10,6 @@ import {
SelectTrigger,
SelectValue,
} from "@basango/ui/components/select";
import { ToggleGroup, ToggleGroupItem } from "@basango/ui/components/toggle-group";
import { differenceInCalendarDays, format, subDays } from "date-fns";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { parseAsInteger, parseAsIsoDate, useQueryStates } from "nuqs";
@@ -136,8 +135,10 @@ export function ChartPeriodPicker({
return match ? String(match.value) : "custom";
}, [calendarRange, options]);
const handlePresetChange = (value: string) => {
if (value === "custom") {
const presetValue = selectValue === "custom" ? undefined : selectValue;
const handlePresetChange = (value?: string) => {
if (!value || value === "custom") {
return;
}
@@ -149,6 +150,10 @@ export function ChartPeriodPicker({
});
};
const handlePresetClick = (value: string) => {
handlePresetChange(value);
};
const handleCalendarSelect = (value: DateRange | undefined) => {
if (value?.from && value?.to) {
setState({
@@ -181,29 +186,56 @@ export function ChartPeriodPicker({
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-screen space-y-4 p-4 sm:w-[520px]" sideOffset={8}>
<Select onValueChange={handlePresetChange} value={selectValue}>
<SelectTrigger>
<SelectValue placeholder="Quick range" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
<SelectItem value="custom">Custom range</SelectItem>
</SelectContent>
</Select>
<PopoverContent align="start" className="w-auto p-0" sideOffset={8}>
<div className="flex flex-col gap-0 sm:flex-row">
<div className="border-b border-border sm:hidden">
<div className="p-4">
<Select onValueChange={handlePresetChange} value={selectValue}>
<SelectTrigger>
<SelectValue placeholder="Quick range" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
<SelectItem value="custom">Custom range</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="hidden flex-col gap-0 border-r border-border py-4 pl-4 pr-4 sm:flex">
{options.map((option) => {
const isActive = presetValue === String(option.value);
<Calendar
mode="range"
numberOfMonths={2}
onSelect={handleCalendarSelect}
selected={(selectedRange ?? calendarRange) as DateRange | undefined}
/>
<div className="flex justify-end gap-2">
return (
<button
className={`rounded-md px-3 py-2 text-left text-sm font-medium transition-colors ${
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
key={option.value}
onClick={() => handlePresetClick(String(option.value))}
type="button"
>
{option.label}
</button>
);
})}
</div>
<div className="p-4">
<Calendar
disabled={{ after: new Date() }}
mode="range"
numberOfMonths={1}
onSelect={handleCalendarSelect}
selected={(selectedRange ?? calendarRange) as DateRange | undefined}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t px-4 py-3">
<Button
onClick={() =>
setState({
@@ -0,0 +1,72 @@
"use client";
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { Card } from "@basango/ui/components/card";
import { Skeleton } from "@basango/ui/components/skeleton";
import { useQuery } from "@tanstack/react-query";
import { Status } from "#dashboard/components/charts/status";
import { Show } from "#dashboard/components/shell/show";
import { useTRPC } from "#dashboard/trpc/client";
import { formatNumber } from "#dashboard/utils/utils";
type DashboardOverview = RouterOutputs["reports"]["getDashboardOverview"];
type OverviewMetric = keyof DashboardOverview;
const LABELS: Record<OverviewMetric, string> = {
articles: "Articles",
sources: "Sources",
users: "Users",
};
interface MetricProps {
delta: DashboardOverview[OverviewMetric]["delta"] | undefined;
label: string;
loading: boolean;
value: number | undefined;
}
function OverviewMetricSkeleton() {
return (
<>
<Skeleton className="h-10 w-40" />
<div className="flex items-center gap-2 text-sm font-medium">
<Skeleton className="h-4 w-52" />
</div>
</>
);
}
function OverviewMetricCard({ delta, label, loading, value }: MetricProps) {
return (
<Card className="flex flex-col gap-2 border-border bg-card p-4">
<p className="text-sm text-muted-foreground">{label}</p>
<Show fallback={<OverviewMetricSkeleton />} when={!loading}>
<p className="text-4xl font-semibold">{formatNumber(value)}</p>
<div className="flex items-center gap-2 text-sm font-medium">
<Status percentage value={delta} />
<span className="text-muted-foreground">vs previous 30 days</span>
</div>
</Show>
</Card>
);
}
export function DashboardOverviewCard() {
const trpc = useTRPC();
const { data, isPending } = useQuery(trpc.reports.getDashboardOverview.queryOptions());
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{(Object.keys(LABELS) as OverviewMetric[]).map((key) => (
<OverviewMetricCard
delta={data?.[key]?.delta}
key={key}
label={LABELS[key]}
loading={isPending && !data}
value={data?.[key]?.total}
/>
))}
</div>
);
}
@@ -12,8 +12,7 @@ import { Input } from "@basango/ui/components/input";
import { SubmitButton } from "@basango/ui/components/submit-button";
import { cn } from "@basango/ui/lib/utils";
import { useMutation } from "@tanstack/react-query";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Controller } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -27,11 +26,9 @@ type LoginValues = z.infer<typeof loginSchema>;
export function LoginForm({ className, ...props }: React.ComponentProps<"form">) {
const router = useRouter();
const params = useParams<{ locale?: string }>();
const searchParams = useSearchParams();
const trpc = useTRPC();
const setUser = useUserStore((state) => state.setUser);
const locale = params?.locale ?? "en";
const form = useZodForm(loginSchema, {
defaultValues: {
@@ -53,20 +50,16 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"form">)
refreshTokenExpiresAt: data.refreshTokenExpiresAt,
});
setUser(data.user);
toast.success("Successfully logged in.");
form.reset();
router.push(searchParams?.get("return_to") ?? `/${locale}/dashboard`);
router.push(searchParams?.get("return_to") ?? `/dashboard`);
router.refresh();
},
}),
);
const handleSubmit = useCallback(
(values: LoginValues) => {
mutation.mutate(values);
},
[mutation],
);
const handleSubmit = (values: LoginValues) => mutation.mutate(values);
return (
<form
@@ -1,16 +1,37 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from "@basango/ui/components/breadcrumb";
import { Separator } from "@basango/ui/components/separator";
import { SidebarTrigger } from "@basango/ui/components/sidebar";
import { Show } from "#dashboard/components/shell/show";
import { ThemeToggle } from "#dashboard/components/theme-toggle";
export function PageHeader() {
type Props = {
title?: string | React.ReactNode;
};
export function PageHeader({ title }: Props) {
return (
<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">
<header className="flex h-16 shrink-0 items-center justify-between gap-2">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
<Show when={title !== undefined}>
<Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbPage>{title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</Show>
</div>
<div className="flex items-center gap-2 px-4">
<div className="flex items-center gap-2">
<ThemeToggle />
</div>
</header>
@@ -1,31 +1,19 @@
import React from "react";
import { PageHeader } from "#dashboard/components/shell/page-header";
interface PageProps {
children: React.ReactNode;
title?: string | React.ReactNode;
leading?: string | React.ReactNode;
header?: React.ReactNode;
}
export const PageLayout = (props: React.PropsWithChildren<PageProps>) => {
const { title, leading, children } = props;
const { title, header = <PageHeader title={title} />, children } = props;
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="container mx-auto space-y-4">
{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 className="flex flex-1 flex-col gap-4 p-4 pt-0">
{header}
{children}
</div>
);
};
@@ -0,0 +1,22 @@
"use client";
import React from "react";
export interface ShowProps<T> {
when: T | null | undefined;
fallback?: React.ReactNode;
children: React.ReactNode | ((props: T) => React.ReactNode);
}
export function Show<T>(props: ShowProps<T>): React.ReactNode {
const { when, fallback, children } = props;
let result: React.ReactNode;
if (!when) {
result = fallback;
} else {
result = typeof children === "function" ? children(when) : children;
}
return result;
}
@@ -16,21 +16,29 @@ import {
SidebarMenuSubItem,
} from "@basango/ui/components/sidebar";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { usePathname } from "next/navigation";
type ParentItem = {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
items?: ChildItem[];
};
type ChildItem = {
title: string;
url: string;
isActive?: boolean;
};
type Props = {
items: ParentItem[];
};
export function AppSidebarContent({ items }: Props) {
const pathname = usePathname();
export function AppSidebarContent({
items,
}: {
items: {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}) {
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
@@ -54,7 +62,10 @@ export function AppSidebarContent({
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<SidebarMenuSubButton
asChild
isActive={subItem.url === pathname || pathname.includes(subItem.url)}
>
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>
@@ -8,14 +8,13 @@ export function AppSidebarInfo() {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
size="lg"
>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Basango Dashboard</span>
<span className="truncate text-xs">v{version}</span>
</div>
<SidebarMenuButton asChild size="lg">
<a href="#">
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Basango Dashboard</span>
<span className="truncate text-xs">v{version}</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -5,7 +5,6 @@ import {
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@basango/ui/components/sidebar";
import { LayoutDashboard, SquareTerminal } from "lucide-react";
import * as React from "react";
@@ -49,7 +48,7 @@ const data = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="icon" {...props}>
<Sidebar variant="inset" {...props}>
<SidebarHeader>
<AppSidebarInfo />
</SidebarHeader>
@@ -59,7 +58,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarFooter>
<AppSidebarUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}