feat(dashboard): more type safety
This commit is contained in:
@@ -31,11 +31,9 @@ export const sourcesRouter = createTRPCRouter({
|
|||||||
return getSourceCategoryShares(ctx.db, input);
|
return getSourceCategoryShares(ctx.db, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPublicationGraph: protectedProcedure
|
getPublications: protectedProcedure.input(getPublicationsSchema).query(async ({ ctx, input }) => {
|
||||||
.input(getPublicationsSchema)
|
return getSourcePublicationGraph(ctx.db, input);
|
||||||
.query(async ({ ctx, input }) => {
|
}),
|
||||||
return getSourcePublicationGraph(ctx.db, input);
|
|
||||||
}),
|
|
||||||
|
|
||||||
list: protectedProcedure.query(async ({ ctx }) => getSources(ctx.db)),
|
list: protectedProcedure.query(async ({ ctx }) => getSources(ctx.db)),
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"#api/*": ["./src/*"],
|
||||||
|
"#db/*": ["../../packages/db/src/*"],
|
||||||
|
"#domain/*": ["../../packages/domain/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"extends": "@basango/tsconfig/base.json",
|
"extends": "@basango/tsconfig/base.json",
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"#crawler/*": ["./src/*"],
|
||||||
|
"#domain/*": ["../../packages/domain/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"extends": "@basango/tsconfig/base.json",
|
"extends": "@basango/tsconfig/base.json",
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Source } from "@basango/domain/models/sources";
|
|
||||||
import { Button } from "@basango/ui/components/button";
|
import { Button } from "@basango/ui/components/button";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { RouterOutputs } from "#api/trpc/routers/_app";
|
||||||
import { SourceCreateModal } from "#dashboard/components/modals/source-create-modal";
|
import { SourceCreateModal } from "#dashboard/components/modals/source-create-modal";
|
||||||
import { PageLayout } from "#dashboard/components/shell/page-layout";
|
import { PageLayout } from "#dashboard/components/shell/page-layout";
|
||||||
import { SourceCard } from "#dashboard/components/source-card";
|
import { SourceCard } from "#dashboard/components/source-card";
|
||||||
@@ -13,11 +13,13 @@ export const metadata: Metadata = {
|
|||||||
title: "Sources | Basango Dashboard",
|
title: "Sources | Basango Dashboard",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Source = RouterOutputs["sources"]["list"][number];
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
prefetch(trpc.sources.get.queryOptions());
|
prefetch(trpc.sources.list.queryOptions());
|
||||||
const sources: Source[] = await queryClient.fetchQuery(trpc.sources.get.queryOptions());
|
const sources = await queryClient.fetchQuery(trpc.sources.list.queryOptions());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
|
|||||||
@@ -23,18 +23,8 @@ type ArticleCardProps = {
|
|||||||
article: Article;
|
article: Article;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDescription(article: Article) {
|
|
||||||
return (
|
|
||||||
article.metadata?.description ??
|
|
||||||
article.excerpt ??
|
|
||||||
"No description was provided for this article."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArticleCard({ article }: ArticleCardProps) {
|
export function ArticleCard({ article }: ArticleCardProps) {
|
||||||
const [copied, setCopied] = React.useState(false);
|
const [copied, setCopied] = React.useState(false);
|
||||||
const description = getDescription(article);
|
|
||||||
const imageUrl = article.image ?? undefined;
|
|
||||||
|
|
||||||
const copyLink = React.useCallback(async () => {
|
const copyLink = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -50,12 +40,12 @@ export function ArticleCard({ article }: ArticleCardProps) {
|
|||||||
<Card className="flex h-full flex-col overflow-hidden border border-border/80 p-0">
|
<Card className="flex h-full flex-col overflow-hidden border border-border/80 p-0">
|
||||||
<CardHeader className="relative h-40 overflow-hidden p-0">
|
<CardHeader className="relative h-40 overflow-hidden p-0">
|
||||||
<div className="relative h-full w-full bg-muted">
|
<div className="relative h-full w-full bg-muted">
|
||||||
{imageUrl ? (
|
{article.image ? (
|
||||||
<img
|
<img
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
className="h-full w-full object-cover transition duration-200 hover:scale-105"
|
className="h-full w-full object-cover transition duration-200 hover:scale-105"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={imageUrl}
|
src={article.image}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||||
@@ -63,7 +53,7 @@ export function ArticleCard({ article }: ArticleCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute left-3 top-3">
|
<div className="absolute left-3 top-3">
|
||||||
<Badge variant="secondary">{article.sourceName}</Badge>
|
<Badge variant="secondary">{article.source?.name}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-3 top-3">
|
<div className="absolute right-3 top-3">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -103,14 +93,18 @@ export function ArticleCard({ article }: ArticleCardProps) {
|
|||||||
{article.title}
|
{article.title}
|
||||||
</Link>
|
</Link>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground line-clamp-3">{description}</p>
|
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||||
|
{article.metadata?.description ??
|
||||||
|
article.excerpt ??
|
||||||
|
"No description was provided for this article."}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex items-center justify-between gap-2 px-4 py-3 text-xs text-muted-foreground">
|
<CardFooter className="flex items-center justify-between gap-2 px-4 py-3 text-xs text-muted-foreground">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatDate(article.publishedAt.toISOString(), "PP", false)}
|
{formatDate(article.publishedAt.toISOString(), "PP", false)}
|
||||||
</span>
|
</span>
|
||||||
<span>{formatRelativeTime(new Date(article.publishedAt))}</span>
|
<span>{formatRelativeTime(article.publishedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>{article.readingTime} min</span>
|
<span>{article.readingTime} min</span>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { Area, AreaChart as BaseAreachart, CartesianGrid, XAxis, YAxis } from "r
|
|||||||
|
|
||||||
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
||||||
|
|
||||||
type AreaChartProps = {
|
type AreaChartProps<T> = {
|
||||||
data: unknown;
|
data: T[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AreaChart({ data }: AreaChartProps) {
|
export function AreaChart<T>({ data }: AreaChartProps<T>) {
|
||||||
return (
|
return (
|
||||||
<BaseAreachart accessibilityLayer data={data}>
|
<BaseAreachart accessibilityLayer data={data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +32,7 @@ export function PublicationGraphChart() {
|
|||||||
const period = useChartPeriodFilter();
|
const period = useChartPeriodFilter();
|
||||||
|
|
||||||
const { data } = useQuery(
|
const { data } = useQuery(
|
||||||
trpc.articles.getPublicationGraph.queryOptions({
|
trpc.articles.getPublications.queryOptions({
|
||||||
range: period.range,
|
range: period.range,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -45,19 +44,19 @@ export function PublicationGraphChart() {
|
|||||||
<CardTitle>{formatNumber(data?.meta?.current)} articles</CardTitle>
|
<CardTitle>{formatNumber(data?.meta?.current)} articles</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<div className="flex items-center justify-start gap-1 text-xs">
|
<div className="flex items-center justify-start gap-1 text-xs">
|
||||||
<Status value={data?.delta} />
|
<Status value={data?.meta?.delta} />
|
||||||
<span className="text-muted-foreground">vs previous</span>
|
<span className="text-muted-foreground">vs previous</span>
|
||||||
</div>
|
</div>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey="articlesPeriod" />
|
<ChartPeriodPicker defaultDays={period.defaultDays} />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||||
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
|
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
|
||||||
<AreaChart data={data?.items} />
|
<AreaChart data={data?.items ?? []} />
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
@@ -68,7 +67,7 @@ export function PublicationGraphChart() {
|
|||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{formatNumber(data?.meta?.current)} vs {formatNumber(data?.meta?.previous)} articles
|
{formatNumber(data?.meta?.current)} vs {formatNumber(data?.meta?.previous)} articles
|
||||||
</span>
|
</span>
|
||||||
<Status icons={false} percentage={true} value={data?.deltaPercentage} />
|
<Status icons={false} percentage={true} value={data?.meta?.delta} />
|
||||||
<span className="text-muted-foreground">period</span>
|
<span className="text-muted-foreground">period</span>
|
||||||
{data?.meta?.previous === 0 && data?.meta?.current === 0 && (
|
{data?.meta?.previous === 0 && data?.meta?.current === 0 && (
|
||||||
<span className="text-muted-foreground">(no articles yet)</span>
|
<span className="text-muted-foreground">(no articles yet)</span>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { Bar, BarChart as BaseBarChart, CartesianGrid, XAxis } from "recharts";
|
|||||||
|
|
||||||
import { formatDate } from "#dashboard/utils/utils";
|
import { formatDate } from "#dashboard/utils/utils";
|
||||||
|
|
||||||
type BarChartProps = {
|
type BarChartProps<T> = {
|
||||||
data: unknown;
|
data: T[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BarChart({ data }: BarChartProps) {
|
export function BarChart<T>({ data }: BarChartProps<T>) {
|
||||||
return (
|
return (
|
||||||
<BaseBarChart accessibilityLayer data={data}>
|
<BaseBarChart accessibilityLayer data={data}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ const DEFAULT_PERIOD_OPTIONS = [
|
|||||||
{ label: "Last 12 months", value: 365 },
|
{ label: "Last 12 months", value: 365 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type DateInput = number | Date | null | undefined;
|
|
||||||
|
|
||||||
const createRangeFromDays = (days: number): DateRange => {
|
const createRangeFromDays = (days: number): DateRange => {
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
|
|
||||||
@@ -75,7 +73,7 @@ export function useChartPeriodFilter(options: ChartPeriodFilterOptions = {}) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [from, to]);
|
}, [from, to]);
|
||||||
|
|
||||||
const range = useMemo(() => {
|
const calendarRange: DateRange | undefined = useMemo(() => {
|
||||||
if (from && to) {
|
if (from && to) {
|
||||||
return { from, to };
|
return { from, to };
|
||||||
}
|
}
|
||||||
@@ -83,7 +81,10 @@ export function useChartPeriodFilter(options: ChartPeriodFilterOptions = {}) {
|
|||||||
return defaultRange;
|
return defaultRange;
|
||||||
}, [defaultRange, from, to]);
|
}, [defaultRange, from, to]);
|
||||||
|
|
||||||
|
const range = useMemo(() => formatDomainDateRange(calendarRange), [calendarRange]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
calendarRange,
|
||||||
defaultDays,
|
defaultDays,
|
||||||
keys: { fromKey, toKey },
|
keys: { fromKey, toKey },
|
||||||
range,
|
range,
|
||||||
@@ -118,19 +119,22 @@ export function ChartPeriodPicker({
|
|||||||
paramKey = "chartPeriod",
|
paramKey = "chartPeriod",
|
||||||
disabled,
|
disabled,
|
||||||
}: ChartPeriodPickerProps & { disabled?: boolean }) {
|
}: ChartPeriodPickerProps & { disabled?: boolean }) {
|
||||||
const { range, selectedRange, keys, setState } = useChartPeriodFilter({ defaultDays, paramKey });
|
const { calendarRange, selectedRange, keys, setState } = useChartPeriodFilter({
|
||||||
|
defaultDays,
|
||||||
|
paramKey,
|
||||||
|
});
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const selectValue = useMemo(() => {
|
const selectValue = useMemo(() => {
|
||||||
if (!range?.from || !range?.to) {
|
if (!calendarRange?.from || !calendarRange?.to) {
|
||||||
return "custom";
|
return "custom";
|
||||||
}
|
}
|
||||||
|
|
||||||
const diff = differenceInCalendarDays(range.to, range.from) + 1;
|
const diff = differenceInCalendarDays(calendarRange.to, calendarRange.from) + 1;
|
||||||
const match = options.find((option) => option.value === diff);
|
const match = options.find((option) => option.value === diff);
|
||||||
|
|
||||||
return match ? String(match.value) : "custom";
|
return match ? String(match.value) : "custom";
|
||||||
}, [options, range]);
|
}, [calendarRange, options]);
|
||||||
|
|
||||||
const handlePresetChange = (value: string) => {
|
const handlePresetChange = (value: string) => {
|
||||||
if (value === "custom") {
|
if (value === "custom") {
|
||||||
@@ -160,7 +164,7 @@ export function ChartPeriodPicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const displayLabel =
|
const displayLabel =
|
||||||
formatDateRange(range) ??
|
formatDateRange(calendarRange) ??
|
||||||
options.find((option) => String(option.value) === selectValue)?.label ??
|
options.find((option) => String(option.value) === selectValue)?.label ??
|
||||||
"Select range";
|
"Select range";
|
||||||
|
|
||||||
@@ -196,7 +200,7 @@ export function ChartPeriodPicker({
|
|||||||
mode="range"
|
mode="range"
|
||||||
numberOfMonths={2}
|
numberOfMonths={2}
|
||||||
onSelect={handleCalendarSelect}
|
onSelect={handleCalendarSelect}
|
||||||
selected={(selectedRange ?? range) as DateRange | undefined}
|
selected={(selectedRange ?? calendarRange) as DateRange | undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@@ -252,8 +256,14 @@ export function ChartLimitToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateRange(range?: { from?: DateInput; to?: DateInput }) {
|
function formatDateRange(range?: DateRange) {
|
||||||
if (!range?.from || !range?.to) return null;
|
if (!range?.from || !range?.to) return null;
|
||||||
|
|
||||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`;
|
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDomainDateRange(range?: DateRange) {
|
||||||
|
if (!range?.from || !range?.to) return undefined;
|
||||||
|
|
||||||
|
return { end: range.to, start: range.from };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Bar, BarChart, Legend, ResponsiveContainer, XAxis, YAxis } from "recharts";
|
import { Bar, BarChart, Legend, ResponsiveContainer, XAxis, YAxis } from "recharts";
|
||||||
|
|
||||||
|
import { RouterOutputs } from "#api/trpc/routers/_app";
|
||||||
import { ChartLimitToggle, useChartLimitFilter } from "#dashboard/components/charts/chart-filters";
|
import { ChartLimitToggle, useChartLimitFilter } from "#dashboard/components/charts/chart-filters";
|
||||||
import { useTRPC } from "#dashboard/trpc/client";
|
import { useTRPC } from "#dashboard/trpc/client";
|
||||||
import { getColorFromName } from "#dashboard/utils/categories";
|
import { getColorFromName } from "#dashboard/utils/categories";
|
||||||
@@ -19,6 +19,8 @@ type Props = {
|
|||||||
sourceId: string;
|
sourceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CategoryShare = RouterOutputs["sources"]["getCategoryShares"]["items"][number];
|
||||||
|
|
||||||
export function CategorySharesChart({ sourceId }: Props) {
|
export function CategorySharesChart({ sourceId }: Props) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { limit } = useChartLimitFilter();
|
const { limit } = useChartLimitFilter();
|
||||||
@@ -29,11 +31,12 @@ export function CategorySharesChart({ sourceId }: Props) {
|
|||||||
limit,
|
limit,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const items: CategoryShare[] = data?.items || [];
|
||||||
|
|
||||||
const chartData = [
|
const chartData = [
|
||||||
{
|
{
|
||||||
name: "Total",
|
name: "Total",
|
||||||
...Object.fromEntries(data?.items.map((item) => [item.category, item.count])),
|
...Object.fromEntries(items.map((item) => [item.category, item.count])),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@ export function CategorySharesChart({ sourceId }: Props) {
|
|||||||
<CardTitle>Category Shares</CardTitle>
|
<CardTitle>Category Shares</CardTitle>
|
||||||
<CardDescription>showing top {limit} categories for this source</CardDescription>
|
<CardDescription>showing top {limit} categories for this source</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<ChartLimitToggle paramKey={`categoryLimit-${sourceId}`} />
|
<ChartLimitToggle />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="-ml-1 h-20">
|
<div className="-ml-1 h-20">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +36,7 @@ export function PublicationGraphChart({ sourceId }: Props) {
|
|||||||
const period = useChartPeriodFilter();
|
const period = useChartPeriodFilter();
|
||||||
|
|
||||||
const { data } = useQuery(
|
const { data } = useQuery(
|
||||||
trpc.sources.getPublicationGraph.queryOptions({
|
trpc.sources.getPublications.queryOptions({
|
||||||
id: sourceId,
|
id: sourceId,
|
||||||
range: period.range,
|
range: period.range,
|
||||||
}),
|
}),
|
||||||
@@ -50,11 +49,11 @@ export function PublicationGraphChart({ sourceId }: Props) {
|
|||||||
<CardTitle>Publication Graph</CardTitle>
|
<CardTitle>Publication Graph</CardTitle>
|
||||||
<CardDescription>Showing total crawled articles for the selected period</CardDescription>
|
<CardDescription>Showing total crawled articles for the selected period</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey={`sourcePeriod-${sourceId}`} />
|
<ChartPeriodPicker defaultDays={period.defaultDays} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||||
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
|
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
|
||||||
<AreaChart data={data?.items} />
|
<AreaChart data={data?.items ?? []} />
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
|
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
|
||||||
import { updateSourceSchema } from "@basango/domain/models/sources";
|
import { updateSourceSchema } from "@basango/domain/models";
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createSourceSchema } from "@basango/domain/models/sources";
|
import { createSourceSchema } from "@basango/domain/models";
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Source } from "@basango/domain/models/sources";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
} from "@basango/ui/components/chart";
|
} from "@basango/ui/components/chart";
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||||
|
|
||||||
|
import { RouterOutputs } from "#api/trpc/routers/_app";
|
||||||
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
@@ -29,7 +29,11 @@ const chartConfig = {
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export function SourceCard({ source }: { source: Source }) {
|
type Props = {
|
||||||
|
source: RouterOutputs["sources"]["list"][number];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SourceCard({ source }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"#api/*": ["../api/src/*"],
|
||||||
|
"#dashboard/*": ["./src/*"],
|
||||||
|
"#db/*": ["../../packages/db/src/*"],
|
||||||
|
"#domain/*": ["../../packages/domain/src/*"]
|
||||||
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
|
|||||||
@@ -35,9 +35,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
|
"clean": "rm -rf .expo node_modules",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
"paths": {
|
||||||
"#mobile/*": ["./*"]
|
"#mobile/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DEFAULT_TIMEZONE } from "@basango/domain/constants";
|
import { DEFAULT_TIMEZONE } from "@basango/domain/constants";
|
||||||
import {
|
import {
|
||||||
|
Article,
|
||||||
Distribution,
|
Distribution,
|
||||||
Distributions,
|
Distributions,
|
||||||
ID,
|
ID,
|
||||||
@@ -143,7 +144,7 @@ export async function getArticles(db: Database, params: GetArticlesParams) {
|
|||||||
.orderBy(desc(articles.publishedAt), desc(articles.id))
|
.orderBy(desc(articles.publishedAt), desc(articles.id))
|
||||||
.limit(pagination.limit + 1);
|
.limit(pagination.limit + 1);
|
||||||
|
|
||||||
return buildPaginatedResult(rows, pagination, {
|
return buildPaginatedResult<Article>(rows, pagination, {
|
||||||
date: "publishedAt",
|
date: "publishedAt",
|
||||||
id: "id",
|
id: "id",
|
||||||
});
|
});
|
||||||
@@ -154,7 +155,7 @@ export async function getArticlesPublicationGraph(
|
|||||||
params: GetPublicationsParams,
|
params: GetPublicationsParams,
|
||||||
): Promise<Publications> {
|
): Promise<Publications> {
|
||||||
const [startDate, endDate] = buildDateRange(params.range);
|
const [startDate, endDate] = buildDateRange(params.range);
|
||||||
const [previousRangeStart, previousRangeEnd] = buildPreviousRange([startDate, endDate]);
|
const [previousStart, previousEnd] = buildPreviousRange([startDate, endDate]);
|
||||||
|
|
||||||
const data = await db.execute<Publication>(sql`
|
const data = await db.execute<Publication>(sql`
|
||||||
WITH bounds AS (
|
WITH bounds AS (
|
||||||
@@ -193,8 +194,8 @@ export async function getArticlesPublicationGraph(
|
|||||||
sql`
|
sql`
|
||||||
SELECT COALESCE(COUNT(*)::int, 0) AS count
|
SELECT COALESCE(COUNT(*)::int, 0) AS count
|
||||||
FROM article a
|
FROM article a
|
||||||
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousRangeStart})
|
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousStart})
|
||||||
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousRangeEnd})
|
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousEnd})
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.then((res) => res.rows);
|
.then((res) => res.rows);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function getSources(db: Database) {
|
|||||||
rows.map(async (row) => ({
|
rows.map(async (row) => ({
|
||||||
...row,
|
...row,
|
||||||
articles: await countArticlesBySourceId(db, row.id),
|
articles: await countArticlesBySourceId(db, row.id),
|
||||||
publicationGraph: await getSourcePublicationGraph(db, { id: row.id }),
|
publications: await getSourcePublicationGraph(db, { id: row.id }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"#db/*": ["./src/*"],
|
||||||
|
"#domain/*": ["../domain/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"exclude": ["node_modules"],
|
"exclude": ["node_modules"],
|
||||||
"extends": "@basango/tsconfig/base.json",
|
"extends": "@basango/tsconfig/base.json",
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { z } from "@hono/zod-openapi";
|
|||||||
|
|
||||||
import { idSchema, sentimentSchema } from "#domain/models/shared";
|
import { idSchema, sentimentSchema } from "#domain/models/shared";
|
||||||
|
|
||||||
|
import { sourceSchema } from "./sources";
|
||||||
|
|
||||||
// schemas
|
// schemas
|
||||||
export const articleMetadataSchema = z.object({
|
export const articleMetadataSchema = z.object({
|
||||||
author: z.string().optional().openapi({
|
author: z.string().optional().openapi({
|
||||||
@@ -70,11 +72,19 @@ export const articleSchema = z.object({
|
|||||||
description: "The date and time when the article was created in the system.",
|
description: "The date and time when the article was created in the system.",
|
||||||
example: "2023-01-01T12:00:00Z",
|
example: "2023-01-01T12:00:00Z",
|
||||||
}),
|
}),
|
||||||
|
excerpt: z.string().optional().openapi({
|
||||||
|
description: "A brief excerpt or summary of the article.",
|
||||||
|
example: "This article discusses the latest advancements in AI technology.",
|
||||||
|
}),
|
||||||
hash: z.string().min(1).openapi({
|
hash: z.string().min(1).openapi({
|
||||||
description: "The unique hash of the article link.",
|
description: "The unique hash of the article link.",
|
||||||
example: "d41d8cd98f00b204e9800998ecf8427e",
|
example: "d41d8cd98f00b204e9800998ecf8427e",
|
||||||
}),
|
}),
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
|
image: z.url().optional().openapi({
|
||||||
|
description: "The URL of the main image associated with the article.",
|
||||||
|
example: "https://example.com/image.jpg",
|
||||||
|
}),
|
||||||
link: z.string().url().openapi({
|
link: z.string().url().openapi({
|
||||||
description: "The URL of the article.",
|
description: "The URL of the article.",
|
||||||
example: "https://example.com/article",
|
example: "https://example.com/article",
|
||||||
@@ -84,6 +94,11 @@ export const articleSchema = z.object({
|
|||||||
description: "The publication date of the article as a Date object.",
|
description: "The publication date of the article as a Date object.",
|
||||||
example: "2023-01-01T00:00:00Z",
|
example: "2023-01-01T00:00:00Z",
|
||||||
}),
|
}),
|
||||||
|
readingTime: z.number().int().min(1).openapi({
|
||||||
|
description: "Estimated reading time of the article in minutes.",
|
||||||
|
example: 5,
|
||||||
|
}),
|
||||||
|
source: sourceSchema.optional(),
|
||||||
sourceId: z.union([z.uuid(), z.string().min(1)]).openapi({
|
sourceId: z.union([z.uuid(), z.string().min(1)]).openapi({
|
||||||
description: "The unique identifier of the source from which the article was crawled.",
|
description: "The unique identifier of the source from which the article was crawled.",
|
||||||
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
|
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"#domain/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"exclude": ["node_modules"],
|
"exclude": ["node_modules"],
|
||||||
"extends": "@basango/tsconfig/base.json",
|
"extends": "@basango/tsconfig/base.json",
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"exclude": ["node_modules"],
|
"exclude": ["node_modules"],
|
||||||
"extends": "@basango/tsconfig/base.json",
|
"extends": "@basango/tsconfig/base.json",
|
||||||
"include": ["src/**/*"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,6 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"paths": {
|
|
||||||
"#api/*": ["../../apps/api/src/*"],
|
|
||||||
"#crawler/*": ["../../apps/crawler/src/*"],
|
|
||||||
"#dashboard/*": ["../../apps/dashboard/src/*"],
|
|
||||||
"#db/*": ["../db/src/*"],
|
|
||||||
"#domain/*": ["../domain/src/*"]
|
|
||||||
},
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user