feat(domain): centralize data definition

This commit is contained in:
2025-11-17 00:04:27 +02:00
parent e7585aa76c
commit f39635e04f
96 changed files with 3474 additions and 1167 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ const nextConfig = {
},
poweredByHeader: false,
reactStrictMode: true,
transpilePackages: ["@basango/ui", "@basango/api"],
transpilePackages: ["@basango/ui", "@basango/api", "@basango/domain"],
};
export default nextConfig;
+7 -1
View File
@@ -1,6 +1,7 @@
{
"dependencies": {
"@basango/api": "workspace:*",
"@basango/domain": "workspace:*",
"@basango/ui": "workspace:*",
"@date-fns/tz": "^1.4.1",
"@hookform/resolvers": "^5.2.2",
@@ -18,10 +19,12 @@
"next-themes": "^0.4.6",
"nuqs": "^2.7.3",
"react": "catalog:",
"react-day-picker": "^9.11.1",
"react-dom": "catalog:",
"react-hook-form": "^7.66.0",
"recharts": "^3.4.1",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"superjson": "^2.2.5",
"zod": "^4.1.12",
"zustand": "^5.0.8"
@@ -34,12 +37,15 @@
"@types/react-dom": "catalog:",
"typescript": "catalog:"
},
"imports": {
"#dashboard/*": "./src/*"
},
"name": "@basango/dashboard",
"private": true,
"scripts": {
"build": "next build",
"clean": "rm -rf .next node_modules",
"dev": "next dev",
"lint": "eslint",
"start": "next start"
}
}
@@ -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&apos;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>
);
}
@@ -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,
};
}
+11 -3
View File
@@ -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) {
+2 -2
View File
@@ -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) {
+1 -12
View File
@@ -1,11 +1,5 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@basango/api/*": ["../api/src/*"],
"@basango/ui/*": ["../../packages/ui/src/*"],
"#dashboard/*": ["./src/*"]
},
"plugins": [
{
"name": "next"
@@ -14,10 +8,5 @@
},
"exclude": ["node_modules"],
"extends": "@basango/tsconfig/nextjs.json",
"include": ["next-env.d.ts", "next.config.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"references": [
{
"path": "../api"
}
]
"include": ["next-env.d.ts", "next.config.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
}