feat(dashboard): add reports
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user