feat(domain): centralize data definition
This commit is contained in:
@@ -1,22 +1,21 @@
|
||||
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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Articles | Basango Dashboard",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
batchPrefetch([trpc.articles.list.infiniteQueryOptions({ limit: 12 })]);
|
||||
|
||||
return (
|
||||
<PageLayout leading="Manage your articles" title="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>
|
||||
</PageLayout>
|
||||
<HydrateClient>
|
||||
<PageLayout leading="Track crawled content and trends" title="Articles">
|
||||
<ArticlesFeed />
|
||||
</PageLayout>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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 { PageLayout } from "#dashboard/components/shell/page-layout";
|
||||
import { HydrateClient, batchPrefetch, trpc } from "#dashboard/trpc/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard | Basango",
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
batchPrefetch([
|
||||
trpc.articles.getPublicationGraph.queryOptions({}),
|
||||
trpc.articles.getSourceDistribution.queryOptions({ limit: 8 }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<PageLayout leading="Keep track of article volume and source coverage" title="Dashboard">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||
<div className="lg:col-span-3">
|
||||
<PublicationGraphChart />
|
||||
</div>
|
||||
<SourceDistributionChart />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Source } from "@basango/domain/models/sources";
|
||||
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 { ArticlesFeed } from "#dashboard/components/articles-feed";
|
||||
import { CategorySharesChart } from "#dashboard/components/charts/sources/category-shares-chart";
|
||||
import { PublicationGraphChart } from "#dashboard/components/charts/sources/publication-graph-chart";
|
||||
import { PageLayout } from "#dashboard/components/shell/page-layout";
|
||||
import { SourceDetailsTab } from "#dashboard/components/source-details-tab";
|
||||
import { HydrateClient, batchPrefetch, getQueryClient, trpc } from "#dashboard/trpc/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -16,11 +19,12 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||
|
||||
batchPrefetch([
|
||||
trpc.sources.getById.queryOptions({ id }),
|
||||
trpc.sources.getCategoryShares.queryOptions({ id }),
|
||||
trpc.sources.getCategoryShares.queryOptions({ id, limit: 10 }),
|
||||
trpc.sources.getPublicationGraph.queryOptions({ id }),
|
||||
trpc.articles.list.infiniteQueryOptions({ limit: 12, sourceId: id }),
|
||||
]);
|
||||
|
||||
const source = await queryClient.fetchQuery(trpc.sources.getById.queryOptions({ id }));
|
||||
const source: Source = await queryClient.fetchQuery(trpc.sources.getById.queryOptions({ id }));
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
@@ -29,20 +33,17 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="articles">Articles</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="space-y-4" value="overview">
|
||||
<SourceCategorySharesChart sourceId={source.id} />
|
||||
<SourcePublicationgGraphChart sourceId={source.id} />
|
||||
<CategorySharesChart sourceId={source.id} />
|
||||
<PublicationGraphChart 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>
|
||||
<ArticlesFeed sourceId={source.id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="details">
|
||||
<SourceDetailsTab source={source} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageLayout>
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
import { RouterOutputs } from "@basango/api/trpc/routers/_app";
|
||||
import { Source } from "@basango/domain/models/sources";
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { SourceCreateModal } from "#dashboard/components/modals/source-create-modal";
|
||||
import { PageLayout } from "#dashboard/components/shell/page-layout";
|
||||
import { SourceCard } from "#dashboard/components/widgets/source-card";
|
||||
import { SourceCard } from "#dashboard/components/source-card";
|
||||
import { HydrateClient, getQueryClient, prefetch, trpc } from "#dashboard/trpc/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sources | Basango Dashboard",
|
||||
};
|
||||
|
||||
type SourceDetails = RouterOutputs["sources"]["get"][number];
|
||||
|
||||
export default async function Page() {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
prefetch(trpc.sources.get.queryOptions());
|
||||
const sources = await queryClient.fetchQuery(trpc.sources.get.queryOptions());
|
||||
const sources: Source[] = await queryClient.fetchQuery(trpc.sources.get.queryOptions());
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<PageLayout leading="Manage your news sources" title="Sources">
|
||||
<div className="mb-6 flex justify-end">
|
||||
<Link href="?createSource=true">
|
||||
<Button type="button">
|
||||
<PlusIcon className="mr-2 size-4" />
|
||||
Add source
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{sources.map((source: SourceDetails) => (
|
||||
{sources.map((source: Source) => (
|
||||
<Link href={`/sources/${source.id}`} key={source.id}>
|
||||
<SourceCard source={source} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SourceCreateModal />
|
||||
</PageLayout>
|
||||
</HydrateClient>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
|
||||
import { Badge } from "@basango/ui/components/badge";
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@basango/ui/components/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@basango/ui/components/dropdown-menu";
|
||||
import { Skeleton } from "@basango/ui/components/skeleton";
|
||||
import { ExternalLink, Link2, MoreHorizontal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDate, formatRelativeTime } from "#dashboard/utils/utils";
|
||||
|
||||
type Article = RouterOutputs["articles"]["list"]["items"][number];
|
||||
|
||||
type ArticleCardProps = {
|
||||
article: Article;
|
||||
};
|
||||
|
||||
function getDescription(article: Article) {
|
||||
return (
|
||||
article.metadata?.description ??
|
||||
article.excerpt ??
|
||||
"No description was provided for this article."
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticleCard({ article }: ArticleCardProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const description = getDescription(article);
|
||||
const imageUrl = article.image ?? undefined;
|
||||
|
||||
const copyLink = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(article.link);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
}, [article.link]);
|
||||
|
||||
return (
|
||||
<Card className="flex h-full flex-col overflow-hidden border border-border/80 p-0">
|
||||
<CardHeader className="relative h-40 overflow-hidden p-0">
|
||||
<div className="relative h-full w-full bg-muted">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
alt={article.title}
|
||||
className="h-full w-full object-cover transition duration-200 hover:scale-105"
|
||||
loading="lazy"
|
||||
src={imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
No image available
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-3 top-3">
|
||||
<Badge variant="secondary">{article.sourceName}</Badge>
|
||||
</div>
|
||||
<div className="absolute right-3 top-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="size-8 rounded-full bg-background/80 backdrop-blur"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={article.link} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open original
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyLink}>
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
{copied ? "Copied!" : "Copy link"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3 p-4">
|
||||
<CardTitle className="text-base leading-tight">
|
||||
<Link
|
||||
className="transition hover:text-primary hover:underline"
|
||||
href={article.link}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{article.title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">{description}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between gap-2 px-4 py-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatDate(article.publishedAt.toISOString(), "PP", false)}
|
||||
</span>
|
||||
<span>{formatRelativeTime(new Date(article.publishedAt))}</span>
|
||||
</div>
|
||||
<span>{article.readingTime} min</span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticleCardSkeleton() {
|
||||
return (
|
||||
<Card className="flex h-full flex-col overflow-hidden p-0">
|
||||
<div className="h-60 w-full bg-muted">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
<CardContent className="flex flex-1 flex-col gap-3 p-4">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between px-4 py-3">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@basango/ui/components/alert";
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useTRPC } from "#dashboard/trpc/client";
|
||||
|
||||
import { ArticleCard, ArticleCardSkeleton } from "./article-card";
|
||||
|
||||
type ArticlesTableProps = {
|
||||
sourceId?: string;
|
||||
};
|
||||
|
||||
const PLACEHOLDER_COUNT = 8;
|
||||
|
||||
export function ArticlesFeed({ sourceId }: ArticlesTableProps) {
|
||||
const trpc = useTRPC();
|
||||
|
||||
const query = useInfiniteQuery(
|
||||
trpc.articles.list.infiniteQueryOptions(
|
||||
{
|
||||
limit: 12,
|
||||
sourceId,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => (lastPage.meta.hasNext ? lastPage.meta.nextCursor : null),
|
||||
initialCursor: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const articles = React.useMemo(
|
||||
() => query.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[query.data],
|
||||
);
|
||||
|
||||
const isInitialLoading = query.isLoading && !query.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{query.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Unable to load articles</AlertTitle>
|
||||
<AlertDescription>
|
||||
{query.error.message ?? "An unexpected error occurred while fetching articles."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isInitialLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: PLACEHOLDER_COUNT }).map((_, index) => (
|
||||
<ArticleCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : articles.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{articles.map((article) => (
|
||||
<ArticleCard article={article} key={article.id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
No articles match your filters yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
{query.hasNextPage ? (
|
||||
<Button
|
||||
disabled={query.isFetchingNextPage}
|
||||
onClick={() => query.fetchNextPage()}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{query.isFetchingNextPage ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
"Load more"
|
||||
)}
|
||||
</Button>
|
||||
) : articles.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">You're all caught up.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { ChartTooltip, ChartTooltipContent } from "@basango/ui/components/chart";
|
||||
import { Area, AreaChart as BaseAreachart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
||||
|
||||
type AreaChartProps = {
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export function AreaChart({ data }: AreaChartProps) {
|
||||
return (
|
||||
<BaseAreachart accessibilityLayer data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="date"
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatDate(String(value))}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatNumber(Number(value))}
|
||||
tickLine={false}
|
||||
width={48}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => formatDate(String(value), "PP")}
|
||||
nameKey="count"
|
||||
/>
|
||||
}
|
||||
cursor={{ stroke: "var(--border)", strokeDasharray: "4 4" }}
|
||||
/>
|
||||
<Area
|
||||
dataKey="count"
|
||||
fill="var(--color-count)"
|
||||
fillOpacity={0.15}
|
||||
stroke="var(--color-count)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</BaseAreachart>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// @ts-nocheck
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@basango/ui/components/card";
|
||||
import { ChartConfig, ChartContainer } from "@basango/ui/components/chart";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { AreaChart } from "#dashboard/components/charts/area-chart";
|
||||
import {
|
||||
ChartPeriodPicker,
|
||||
useChartPeriodFilter,
|
||||
} from "#dashboard/components/charts/chart-filters";
|
||||
import { Status } from "#dashboard/components/charts/status";
|
||||
import { useTRPC } from "#dashboard/trpc/client";
|
||||
import { formatNumber } from "#dashboard/utils/utils";
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
color: "var(--chart-1)",
|
||||
label: "Articles",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function PublicationGraphChart() {
|
||||
const trpc = useTRPC();
|
||||
const period = useChartPeriodFilter();
|
||||
|
||||
const { data } = useQuery(
|
||||
trpc.articles.getPublicationGraph.queryOptions({
|
||||
range: period.range,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="pt-0">
|
||||
<CardHeader className="flex items-start gap-2 space-y-0 border-b py-5 sm:flex-row sm:items-center">
|
||||
<div className="grid flex-1 gap-1">
|
||||
<CardTitle>{formatNumber(data?.meta?.current)} articles</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="flex items-center justify-start gap-1 text-xs">
|
||||
<Status value={data?.delta} />
|
||||
<span className="text-muted-foreground">vs previous</span>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey="articlesPeriod" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
|
||||
<AreaChart data={data?.items} />
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<CardDescription>
|
||||
Showing total crawled articles for the selected period,
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatNumber(data?.meta?.current)} vs {formatNumber(data?.meta?.previous)} articles
|
||||
</span>
|
||||
<Status icons={false} percentage={true} value={data?.deltaPercentage} />
|
||||
<span className="text-muted-foreground">period</span>
|
||||
{data?.meta?.previous === 0 && data?.meta?.current === 0 && (
|
||||
<span className="text-muted-foreground">(no articles yet)</span>
|
||||
)}
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// @ts-nocheck
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@basango/ui/components/card";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@basango/ui/components/chart";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Cell, Pie, PieChart } from "recharts";
|
||||
|
||||
import { useTRPC } from "#dashboard/trpc/client";
|
||||
import { getColorFromName } from "#dashboard/utils/categories";
|
||||
import { formatNumber } from "#dashboard/utils/utils";
|
||||
|
||||
const chartConfig = {} satisfies ChartConfig;
|
||||
|
||||
export function SourceDistributionChart() {
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { data } = useQuery(
|
||||
trpc.articles.getSourceDistribution.queryOptions({
|
||||
limit: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="pt-0">
|
||||
<CardHeader className="flex items-start gap-2 space-y-0 border-b py-5 sm:flex-row sm:items-center">
|
||||
<div className="grid flex-1 gap-1">
|
||||
<CardTitle>Source distribution</CardTitle>
|
||||
<CardDescription>Share of articles by source</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer className="mx-auto aspect-square max-h-80 w-full" config={chartConfig}>
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent nameKey="name" />} />
|
||||
<Pie
|
||||
data={data?.items}
|
||||
dataKey="count"
|
||||
innerRadius={70}
|
||||
nameKey="name"
|
||||
outerRadius={110}
|
||||
paddingAngle={2}
|
||||
strokeWidth={0}
|
||||
>
|
||||
{data?.items.map((item) => (
|
||||
<Cell fill={getColorFromName(item.name)} key={item.id} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{data?.items.map((item) => (
|
||||
<li className="flex items-center justify-between text-sm" key={item.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: getColorFromName(item.name) }}
|
||||
/>
|
||||
<span className="font-medium leading-none">{item.name}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatNumber(item.count)} ({item.percentage}%)
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { ChartTooltip, ChartTooltipContent } from "@basango/ui/components/chart";
|
||||
import { Bar, BarChart as BaseBarChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import { formatDate } from "#dashboard/utils/utils";
|
||||
|
||||
type BarChartProps = {
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export function BarChart({ data }: BarChartProps) {
|
||||
return (
|
||||
<BaseBarChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="date"
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatDate(value)}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
|
||||
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
|
||||
</BaseBarChart>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
import { Calendar } from "@basango/ui/components/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@basango/ui/components/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
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";
|
||||
import { useMemo, useState } from "react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
|
||||
const DEFAULT_PERIOD_OPTIONS = [
|
||||
{ label: "Last 7 days", value: 7 },
|
||||
{ label: "Last 30 days", value: 30 },
|
||||
{ label: "Last 3 months", value: 90 },
|
||||
{ label: "Last 6 months", value: 180 },
|
||||
{ label: "Last 12 months", value: 365 },
|
||||
] as const;
|
||||
|
||||
type DateInput = number | Date | null | undefined;
|
||||
|
||||
const createRangeFromDays = (days: number): DateRange => {
|
||||
const end = new Date();
|
||||
|
||||
return {
|
||||
from: subDays(end, Math.max(days - 1, 0)),
|
||||
to: end,
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_LIMIT_OPTIONS = [
|
||||
{ label: "Top 10", value: 10 },
|
||||
{ label: "Top 20", value: 20 },
|
||||
{ label: "Top 50", value: 50 },
|
||||
] as const;
|
||||
|
||||
type ChartPeriodFilterOptions = {
|
||||
defaultDays?: number;
|
||||
paramKey?: string;
|
||||
};
|
||||
|
||||
type ChartLimitFilterOptions = {
|
||||
defaultValue?: number;
|
||||
paramKey?: string;
|
||||
};
|
||||
|
||||
export function useChartPeriodFilter(options: ChartPeriodFilterOptions = {}) {
|
||||
const { defaultDays = 30, paramKey = "chartPeriod" } = options;
|
||||
const fromKey = `${paramKey}From`;
|
||||
const toKey = `${paramKey}To`;
|
||||
|
||||
const defaultRange = useMemo(() => createRangeFromDays(defaultDays), [defaultDays]);
|
||||
|
||||
const [state, setState] = useQueryStates({
|
||||
[fromKey]: parseAsIsoDate,
|
||||
[toKey]: parseAsIsoDate,
|
||||
});
|
||||
|
||||
const from = state[fromKey] ?? undefined;
|
||||
const to = state[toKey] ?? undefined;
|
||||
|
||||
const selectedRange = useMemo(() => {
|
||||
if (from || to) {
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [from, to]);
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (from && to) {
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
return defaultRange;
|
||||
}, [defaultRange, from, to]);
|
||||
|
||||
return {
|
||||
defaultDays,
|
||||
keys: { fromKey, toKey },
|
||||
range,
|
||||
selectedRange,
|
||||
setState,
|
||||
};
|
||||
}
|
||||
|
||||
export function useChartLimitFilter(options: ChartLimitFilterOptions = {}) {
|
||||
const { defaultValue = 10, paramKey = "chartLimit" } = options;
|
||||
const [state, setState] = useQueryStates({
|
||||
[paramKey]: parseAsInteger.withDefault(defaultValue),
|
||||
});
|
||||
|
||||
const limit = state[paramKey];
|
||||
|
||||
return {
|
||||
limit,
|
||||
setLimit: (value: number) => {
|
||||
setState({ [paramKey]: value });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ChartPeriodPickerProps = ChartPeriodFilterOptions & {
|
||||
options?: ReadonlyArray<{ label: string; value: number }>;
|
||||
};
|
||||
|
||||
export function ChartPeriodPicker({
|
||||
defaultDays = 30,
|
||||
options = DEFAULT_PERIOD_OPTIONS,
|
||||
paramKey = "chartPeriod",
|
||||
disabled,
|
||||
}: ChartPeriodPickerProps & { disabled?: boolean }) {
|
||||
const { range, selectedRange, keys, setState } = useChartPeriodFilter({ defaultDays, paramKey });
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectValue = useMemo(() => {
|
||||
if (!range?.from || !range?.to) {
|
||||
return "custom";
|
||||
}
|
||||
|
||||
const diff = differenceInCalendarDays(range.to, range.from) + 1;
|
||||
const match = options.find((option) => option.value === diff);
|
||||
|
||||
return match ? String(match.value) : "custom";
|
||||
}, [options, range]);
|
||||
|
||||
const handlePresetChange = (value: string) => {
|
||||
if (value === "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
const presetRange = createRangeFromDays(Number(value));
|
||||
|
||||
setState({
|
||||
[keys.fromKey]: presetRange.from ?? null,
|
||||
[keys.toKey]: presetRange.to ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCalendarSelect = (value: DateRange | undefined) => {
|
||||
if (value?.from && value?.to) {
|
||||
setState({
|
||||
[keys.fromKey]: value.from,
|
||||
[keys.toKey]: value.to,
|
||||
});
|
||||
} else {
|
||||
setState({
|
||||
[keys.fromKey]: null,
|
||||
[keys.toKey]: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const displayLabel =
|
||||
formatDateRange(range) ??
|
||||
options.find((option) => String(option.value) === selectValue)?.label ??
|
||||
"Select range";
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild disabled={disabled}>
|
||||
<Button
|
||||
className="w-full justify-start gap-2 text-left font-medium sm:w-72"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{displayLabel}</span>
|
||||
<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>
|
||||
|
||||
<Calendar
|
||||
mode="range"
|
||||
numberOfMonths={2}
|
||||
onSelect={handleCalendarSelect}
|
||||
selected={(selectedRange ?? range) as DateRange | undefined}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
setState({
|
||||
[keys.fromKey]: null,
|
||||
[keys.toKey]: null,
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)} type="button">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type ChartLimitToggleProps = ChartLimitFilterOptions & {
|
||||
options?: ReadonlyArray<{ label: string; value: number }>;
|
||||
};
|
||||
|
||||
export function ChartLimitToggle({
|
||||
defaultValue = 10,
|
||||
options = DEFAULT_LIMIT_OPTIONS,
|
||||
paramKey = "chartLimit",
|
||||
}: ChartLimitToggleProps) {
|
||||
const { limit, setLimit } = useChartLimitFilter({ defaultValue, paramKey });
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setLimit(Number(value));
|
||||
}
|
||||
}}
|
||||
type="single"
|
||||
value={String(limit)}
|
||||
variant="outline"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<ToggleGroupItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateRange(range?: { from?: DateInput; to?: DateInput }) {
|
||||
if (!range?.from || !range?.to) return null;
|
||||
|
||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"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(value)}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
|
||||
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+11
-27
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -7,11 +8,10 @@ import {
|
||||
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 { ChartLimitToggle, useChartLimitFilter } from "#dashboard/components/charts/chart-filters";
|
||||
import { useTRPC } from "#dashboard/trpc/client";
|
||||
import { getColorFromName } from "#dashboard/utils/categories";
|
||||
|
||||
@@ -19,30 +19,24 @@ type Props = {
|
||||
sourceId: string;
|
||||
};
|
||||
|
||||
export function SourceCategorySharesChart({ sourceId }: Props) {
|
||||
export function CategorySharesChart({ sourceId }: Props) {
|
||||
const trpc = useTRPC();
|
||||
const [limit, setLimit] = useState(10);
|
||||
const { limit } = useChartLimitFilter();
|
||||
|
||||
const { data } = useQuery(
|
||||
trpc.sources.getCategoryShares.queryOptions({
|
||||
id: sourceId,
|
||||
limit: limit,
|
||||
limit,
|
||||
}),
|
||||
);
|
||||
const items = data?.items ?? [];
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
name: "Total",
|
||||
...Object.fromEntries(items.map((item) => [item.category, item.count])),
|
||||
...Object.fromEntries(data?.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">
|
||||
@@ -50,17 +44,7 @@ export function SourceCategorySharesChart({ sourceId }: Props) {
|
||||
<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>
|
||||
<ChartLimitToggle paramKey={`categoryLimit-${sourceId}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="-ml-1 h-20">
|
||||
@@ -80,15 +64,15 @@ export function SourceCategorySharesChart({ sourceId }: Props) {
|
||||
/>
|
||||
<XAxis axisLine={false} fontSize={12} hide tickLine={false} type="number" />
|
||||
<Legend align="left" iconSize={8} iconType="circle" />
|
||||
{barData.map((entry, index) => (
|
||||
{data?.items.map((entry, index) => (
|
||||
<Bar
|
||||
barSize={16}
|
||||
className="transition-all delay-75"
|
||||
dataKey={entry.name}
|
||||
fill={entry.fill}
|
||||
dataKey={entry.category}
|
||||
fill={getColorFromName(entry.category)}
|
||||
key={`bar-${index}`}
|
||||
radius={
|
||||
index === 0 ? [4, 0, 0, 4] : index === barData.length - 1 ? [0, 4, 4, 0] : 0
|
||||
index === 0 ? [4, 0, 0, 4] : index === data?.items.length - 1 ? [0, 4, 4, 0] : 0
|
||||
}
|
||||
stackId="category"
|
||||
/>
|
||||
@@ -0,0 +1,62 @@
|
||||
// @ts-nocheck
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@basango/ui/components/card";
|
||||
import { ChartConfig, ChartContainer } from "@basango/ui/components/chart";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { AreaChart } from "#dashboard/components/charts/area-chart";
|
||||
import {
|
||||
ChartPeriodPicker,
|
||||
useChartPeriodFilter,
|
||||
} from "#dashboard/components/charts/chart-filters";
|
||||
import { useTRPC } from "#dashboard/trpc/client";
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
color: "var(--chart-2)",
|
||||
label: "Articles",
|
||||
},
|
||||
views: {
|
||||
label: "Articles",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
type Props = {
|
||||
sourceId: string;
|
||||
};
|
||||
|
||||
export function PublicationGraphChart({ sourceId }: Props) {
|
||||
const trpc = useTRPC();
|
||||
const period = useChartPeriodFilter();
|
||||
|
||||
const { data } = useQuery(
|
||||
trpc.sources.getPublicationGraph.queryOptions({
|
||||
id: sourceId,
|
||||
range: period.range,
|
||||
}),
|
||||
);
|
||||
|
||||
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 selected period</CardDescription>
|
||||
</div>
|
||||
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey={`sourcePeriod-${sourceId}`} />
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
|
||||
<AreaChart data={data?.items} />
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Delta } from "@basango/domain/models";
|
||||
import { cn } from "@basango/ui/lib/utils";
|
||||
import { ArrowDownRightIcon, ArrowUpRightIcon } from "lucide-react";
|
||||
|
||||
import { formatNumber } from "#dashboard/utils/utils";
|
||||
|
||||
type StatusProps = {
|
||||
value: Delta | undefined;
|
||||
percentage?: boolean;
|
||||
icons?: boolean;
|
||||
sign?: boolean;
|
||||
};
|
||||
|
||||
export function Status({ value, percentage = false, icons = true, sign = true }: StatusProps) {
|
||||
if (value === undefined) {
|
||||
return <span className="text-muted-foreground">0</span>;
|
||||
}
|
||||
|
||||
const color = value.delta >= 0 ? "text-emerald-600" : "text-rose-600";
|
||||
const icon =
|
||||
value.delta >= 0 ? (
|
||||
<ArrowUpRightIcon className={cn("size-4", color)} />
|
||||
) : (
|
||||
<ArrowDownRightIcon className={cn("size-4", color)} />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{icons && icon}
|
||||
<span className={cn("font-semibold", color)}>
|
||||
{sign && value.sign}
|
||||
{percentage
|
||||
? `${formatNumber(Math.abs(value.percentage))}%`
|
||||
: formatNumber(Math.abs(value.delta))}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
|
||||
import { updateSourceSchema } from "@basango/domain/models/sources";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@basango/ui/components/field";
|
||||
import { Input } from "@basango/ui/components/input";
|
||||
import { SubmitButton } from "@basango/ui/components/submit-button";
|
||||
import { Textarea } from "@basango/ui/components/textarea";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useZodForm } from "#dashboard/hooks/use-zod-form";
|
||||
import { useTRPC } from "#dashboard/trpc/client";
|
||||
|
||||
const baseSchema = updateSourceSchema.pick({
|
||||
description: true,
|
||||
displayName: true,
|
||||
id: true,
|
||||
name: true,
|
||||
});
|
||||
|
||||
const sourceEditSchema = z.object({
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
})
|
||||
.pipe(baseSchema.shape.description),
|
||||
displayName: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
})
|
||||
.pipe(baseSchema.shape.displayName),
|
||||
id: baseSchema.shape.id,
|
||||
name: z.string().trim().pipe(baseSchema.shape.name),
|
||||
});
|
||||
|
||||
type SourceEditValues = z.infer<typeof sourceEditSchema>;
|
||||
|
||||
type Props = {
|
||||
source: RouterOutputs["sources"]["getById"];
|
||||
};
|
||||
|
||||
export function SourceEditForm({ source }: Props) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useZodForm(sourceEditSchema, {
|
||||
defaultValues: {
|
||||
description: source.description ?? "",
|
||||
displayName: source.displayName ?? "",
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
description: source.description ?? "",
|
||||
displayName: source.displayName ?? "",
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
});
|
||||
}, [form, source.description, source.displayName, source.id, source.name]);
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.sources.update.mutationOptions({
|
||||
onError(error) {
|
||||
toast.error(error.message ?? "Unable to update source.");
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success("Source updated successfully.");
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.sources.list.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.sources.getById.queryKey({ id: source.id }),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: SourceEditValues) => {
|
||||
mutation.mutate(values);
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoComplete="off"
|
||||
disabled={mutation.isPending}
|
||||
id={field.name}
|
||||
placeholder="radiookapi.com"
|
||||
/>
|
||||
<FieldDescription>Internal identifier of the source.</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="displayName"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Display name</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoComplete="off"
|
||||
disabled={mutation.isPending}
|
||||
id={field.name}
|
||||
placeholder="Radio Okapi"
|
||||
/>
|
||||
<FieldDescription>Optional friendly label shown in the dashboard.</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Description</FieldLabel>
|
||||
<Textarea
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
disabled={mutation.isPending}
|
||||
id={field.name}
|
||||
placeholder="Short summary about the source..."
|
||||
rows={4}
|
||||
/>
|
||||
<FieldDescription>Optional summary shown across the product.</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<SubmitButton className="w-full" isSubmitting={mutation.isPending} type="submit">
|
||||
Save changes
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { createSourceSchema } from "@basango/domain/models/sources";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@basango/ui/components/field";
|
||||
import { Input } from "@basango/ui/components/input";
|
||||
import { SubmitButton } from "@basango/ui/components/submit-button";
|
||||
import { Textarea } from "@basango/ui/components/textarea";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useZodForm } from "#dashboard/hooks/use-zod-form";
|
||||
import { useTRPC } from "#dashboard/trpc/client";
|
||||
|
||||
const baseSchema = createSourceSchema.pick({
|
||||
description: true,
|
||||
displayName: true,
|
||||
name: true,
|
||||
url: true,
|
||||
});
|
||||
|
||||
const sourceFormSchema = z.object({
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
})
|
||||
.pipe(baseSchema.shape.description),
|
||||
displayName: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
})
|
||||
.pipe(baseSchema.shape.displayName),
|
||||
name: z.string().trim().pipe(baseSchema.shape.name),
|
||||
url: z.string().trim().pipe(baseSchema.shape.url),
|
||||
});
|
||||
|
||||
type SourceFormValues = z.infer<typeof sourceFormSchema>;
|
||||
|
||||
type SourceFormProps = {
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export function SourceForm({ onSuccess }: SourceFormProps) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const form = useZodForm(sourceFormSchema, {
|
||||
defaultValues: {
|
||||
description: "",
|
||||
displayName: "",
|
||||
name: "",
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.sources.create.mutationOptions({
|
||||
onError(error) {
|
||||
toast.error(error.message ?? "Unable to create source.");
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success("Source created successfully.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: trpc.sources.list.queryKey(),
|
||||
});
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: SourceFormValues) => {
|
||||
mutation.mutate({
|
||||
...values,
|
||||
});
|
||||
},
|
||||
[mutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoComplete="off"
|
||||
disabled={mutation.isPending}
|
||||
id={field.name}
|
||||
placeholder="radiookapi.com"
|
||||
/>
|
||||
<FieldDescription>
|
||||
This should match the unique identifier of the source.
|
||||
</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="displayName"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Display name</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoComplete="off"
|
||||
disabled={mutation.isPending}
|
||||
id={field.name}
|
||||
placeholder="Radio Okapi"
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Website URL</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoComplete="off"
|
||||
disabled={mutation.isPending}
|
||||
id={field.name}
|
||||
placeholder="https://techcrunch.com"
|
||||
type="url"
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Description</FieldLabel>
|
||||
<Textarea
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
disabled={mutation.isPending}
|
||||
id={field.name}
|
||||
placeholder="Short summary about the source..."
|
||||
rows={4}
|
||||
/>
|
||||
<FieldDescription>
|
||||
Optional brief description (up to 1024 characters).
|
||||
</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<SubmitButton className="w-full" isSubmitting={mutation.isPending} type="submit">
|
||||
Create source
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@basango/ui/components/dialog";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { SourceForm } from "#dashboard/components/forms/source-form";
|
||||
import { useSourceParams } from "#dashboard/hooks/use-source-params";
|
||||
|
||||
export function SourceCreateModal() {
|
||||
const { createSource, setParams } = useSourceParams();
|
||||
const isOpen = Boolean(createSource);
|
||||
|
||||
const openDialog = useCallback(() => {
|
||||
void setParams({ createSource: true });
|
||||
}, [setParams]);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
void setParams(null);
|
||||
}, [setParams]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
openDialog();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
}}
|
||||
open={isOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new source</DialogTitle>
|
||||
<DialogDescription>Add a news outlet to start tracking its articles.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SourceForm onSuccess={closeDialog} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export const PageLayout = (props: React.PropsWithChildren<PageProps>) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||
<div className="container mx-auto">
|
||||
<div className="container mx-auto space-y-4">
|
||||
{title && (
|
||||
<div className="mb-8 flex items-center justify-between space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from "@basango/ui/components/sidebar";
|
||||
import { SquareTerminal } from "lucide-react";
|
||||
import { LayoutDashboard, SquareTerminal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { AppSidebarContent } from "./app-sidebar-content";
|
||||
@@ -16,6 +16,18 @@ import { AppSidebarUser } from "./app-sidebar-user";
|
||||
|
||||
const data = {
|
||||
main: [
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
isActive: true,
|
||||
items: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/dashboard",
|
||||
},
|
||||
],
|
||||
title: "Overview",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
icon: SquareTerminal,
|
||||
isActive: true,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Source } from "@basango/domain/models/sources";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@basango/ui/components/card";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@basango/ui/components/chart";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
color: "var(--chart-2)",
|
||||
label: "Articles",
|
||||
},
|
||||
views: {
|
||||
label: "Articles",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function SourceCard({ source }: { source: Source }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{source.name}</CardTitle>
|
||||
<CardDescription>{source.id}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart accessibilityLayer data={source.publications?.items}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="date"
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatDate(String(value))}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatNumber(Number(value))}
|
||||
tickLine={false}
|
||||
width={48}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => formatDate(String(value), "PP")}
|
||||
nameKey="count"
|
||||
/>
|
||||
}
|
||||
cursor={{ stroke: "var(--border)", strokeDasharray: "4 4" }}
|
||||
/>
|
||||
<Area
|
||||
dataKey="count"
|
||||
fill="var(--color-count)"
|
||||
fillOpacity={0.15}
|
||||
stroke="var(--color-count)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col items-start gap-2 text-sm">
|
||||
<div className="flex gap-2 leading-none font-medium">
|
||||
{formatNumber(source.articles)} articles crawled
|
||||
</div>
|
||||
<div className="text-muted-foreground leading-none">Showing last 30 days</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { Source } from "@basango/domain/models";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@basango/ui/components/card";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SourceEditForm } from "#dashboard/components/forms/source-edit-form";
|
||||
|
||||
export function SourceDetailsTab({ source }: { source: Source }) {
|
||||
const credibility = source.credibility;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Source details</CardTitle>
|
||||
<CardDescription>Key properties of this publication.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-6 sm:grid-cols-2">
|
||||
<DetailItem label="Name" value={source.name} />
|
||||
<DetailItem label="Display name" value={source.displayName ?? "—"} />
|
||||
<DetailItem
|
||||
label="Website"
|
||||
value={
|
||||
<Link
|
||||
className="text-primary underline underline-offset-4"
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
>
|
||||
{source.url}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<DetailItem label="Description" value={source.description ?? "Not provided"} />
|
||||
<DetailItem label="Bias" value={credibility?.bias ?? "Unknown"} />
|
||||
<DetailItem label="Reliability" value={credibility?.reliability ?? "Unknown"} />
|
||||
<DetailItem label="Transparency" value={credibility?.transparency ?? "Unknown"} />
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Edit source</CardTitle>
|
||||
<CardDescription>
|
||||
Update the name or description shown inside the dashboard.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SourceEditForm source={source} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<dt className="text-muted-foreground text-sm">{label}</dt>
|
||||
<dd className="text-base font-medium wrap-break-word">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@basango/ui/components/card";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@basango/ui/components/chart";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
color: "var(--chart-2)",
|
||||
label: "Articles",
|
||||
},
|
||||
views: {
|
||||
label: "Articles",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
type SourceDetails = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string | null;
|
||||
url: string;
|
||||
description: string;
|
||||
publicationGraph: {
|
||||
items: { date: string; count: number }[];
|
||||
total: number;
|
||||
};
|
||||
credibility: {
|
||||
bias: string;
|
||||
reliability: string;
|
||||
transparency: string;
|
||||
};
|
||||
articles: number;
|
||||
};
|
||||
|
||||
export function SourceCard({ source }: { source: SourceDetails }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{source.name}</CardTitle>
|
||||
<CardDescription>{source.id}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart accessibilityLayer data={source.publicationGraph.items}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="date"
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatDate(value)}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
|
||||
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col items-start gap-2 text-sm">
|
||||
<div className="flex gap-2 leading-none font-medium">
|
||||
{formatNumber(source.articles)} articles crawled
|
||||
</div>
|
||||
<div className="text-muted-foreground leading-none">
|
||||
Showing last {source.publicationGraph.total} days
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { parseAsBoolean, useQueryStates } from "nuqs";
|
||||
|
||||
export function useSourceParams() {
|
||||
const [params, setParams] = useQueryStates({
|
||||
createSource: parseAsBoolean,
|
||||
});
|
||||
|
||||
return {
|
||||
...params,
|
||||
setParams,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import "server-only";
|
||||
import type { AppRouter } from "@basango/api/trpc/routers/_app";
|
||||
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
|
||||
import { type TRPCQueryOptions, createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
import {
|
||||
type TRPCInfiniteQueryOptions,
|
||||
type TRPCQueryOptions,
|
||||
createTRPCOptionsProxy,
|
||||
} from "@trpc/tanstack-react-query";
|
||||
import { cache } from "react";
|
||||
import superjson from "superjson";
|
||||
|
||||
@@ -46,7 +50,11 @@ export function HydrateClient(props: { children: React.ReactNode }) {
|
||||
return <HydrationBoundary state={dehydrate(queryClient)}>{props.children}</HydrationBoundary>;
|
||||
}
|
||||
|
||||
export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptions: T) {
|
||||
type AnyQueryOptions =
|
||||
| ReturnType<TRPCQueryOptions<any>>
|
||||
| ReturnType<TRPCInfiniteQueryOptions<any>>;
|
||||
|
||||
export function prefetch<T extends AnyQueryOptions>(queryOptions: T) {
|
||||
const queryClient = getQueryClient();
|
||||
if (queryOptions.queryKey[1]?.type === "infinite") {
|
||||
void queryClient.prefetchInfiniteQuery(queryOptions as any);
|
||||
@@ -55,7 +63,7 @@ export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptio
|
||||
}
|
||||
}
|
||||
|
||||
export function batchPrefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptionsArray: T[]) {
|
||||
export function batchPrefetch<T extends AnyQueryOptions>(queryOptionsArray: T[]) {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
for (const queryOptions of queryOptionsArray) {
|
||||
|
||||
@@ -42,8 +42,8 @@ export function formatDate(date: string, dateFormat?: string | null, checkYear =
|
||||
return format(new Date(date), dateFormat ?? "P");
|
||||
}
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
return Intl.NumberFormat("en-US").format(value);
|
||||
export function formatNumber(value: number | undefined): string {
|
||||
return Intl.NumberFormat("en-US").format(value ?? 0);
|
||||
}
|
||||
|
||||
export function getInitials(value: string) {
|
||||
|
||||
Reference in New Issue
Block a user