feat(dashboard): more type safety
This commit is contained in:
@@ -31,11 +31,9 @@ export const sourcesRouter = createTRPCRouter({
|
||||
return getSourceCategoryShares(ctx.db, input);
|
||||
}),
|
||||
|
||||
getPublicationGraph: protectedProcedure
|
||||
.input(getPublicationsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getSourcePublicationGraph(ctx.db, input);
|
||||
}),
|
||||
getPublications: protectedProcedure.input(getPublicationsSchema).query(async ({ ctx, input }) => {
|
||||
return getSourcePublicationGraph(ctx.db, input);
|
||||
}),
|
||||
|
||||
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",
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"#crawler/*": ["./src/*"],
|
||||
"#domain/*": ["../../packages/domain/src/*"]
|
||||
}
|
||||
},
|
||||
"extends": "@basango/tsconfig/base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { RouterOutputs } from "#api/trpc/routers/_app";
|
||||
import { SourceCreateModal } from "#dashboard/components/modals/source-create-modal";
|
||||
import { PageLayout } from "#dashboard/components/shell/page-layout";
|
||||
import { SourceCard } from "#dashboard/components/source-card";
|
||||
@@ -13,11 +13,13 @@ export const metadata: Metadata = {
|
||||
title: "Sources | Basango Dashboard",
|
||||
};
|
||||
|
||||
type Source = RouterOutputs["sources"]["list"][number];
|
||||
|
||||
export default async function Page() {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
prefetch(trpc.sources.get.queryOptions());
|
||||
const sources: Source[] = await queryClient.fetchQuery(trpc.sources.get.queryOptions());
|
||||
prefetch(trpc.sources.list.queryOptions());
|
||||
const sources = await queryClient.fetchQuery(trpc.sources.list.queryOptions());
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
|
||||
@@ -23,18 +23,8 @@ 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 {
|
||||
@@ -50,12 +40,12 @@ export function ArticleCard({ article }: ArticleCardProps) {
|
||||
<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 ? (
|
||||
{article.image ? (
|
||||
<img
|
||||
alt={article.title}
|
||||
className="h-full w-full object-cover transition duration-200 hover:scale-105"
|
||||
loading="lazy"
|
||||
src={imageUrl}
|
||||
src={article.image}
|
||||
/>
|
||||
) : (
|
||||
<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 className="absolute left-3 top-3">
|
||||
<Badge variant="secondary">{article.sourceName}</Badge>
|
||||
<Badge variant="secondary">{article.source?.name}</Badge>
|
||||
</div>
|
||||
<div className="absolute right-3 top-3">
|
||||
<DropdownMenu>
|
||||
@@ -103,14 +93,18 @@ export function ArticleCard({ article }: ArticleCardProps) {
|
||||
{article.title}
|
||||
</Link>
|
||||
</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>
|
||||
<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>
|
||||
<span>{formatRelativeTime(article.publishedAt)}</span>
|
||||
</div>
|
||||
<span>{article.readingTime} min</span>
|
||||
</CardFooter>
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Area, AreaChart as BaseAreachart, CartesianGrid, XAxis, YAxis } from "r
|
||||
|
||||
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
||||
|
||||
type AreaChartProps = {
|
||||
data: unknown;
|
||||
type AreaChartProps<T> = {
|
||||
data: T[];
|
||||
};
|
||||
|
||||
export function AreaChart({ data }: AreaChartProps) {
|
||||
export function AreaChart<T>({ data }: AreaChartProps<T>) {
|
||||
return (
|
||||
<BaseAreachart accessibilityLayer data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -33,7 +32,7 @@ export function PublicationGraphChart() {
|
||||
const period = useChartPeriodFilter();
|
||||
|
||||
const { data } = useQuery(
|
||||
trpc.articles.getPublicationGraph.queryOptions({
|
||||
trpc.articles.getPublications.queryOptions({
|
||||
range: period.range,
|
||||
}),
|
||||
);
|
||||
@@ -45,19 +44,19 @@ export function PublicationGraphChart() {
|
||||
<CardTitle>{formatNumber(data?.meta?.current)} articles</CardTitle>
|
||||
<CardDescription>
|
||||
<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>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey="articlesPeriod" />
|
||||
<ChartPeriodPicker defaultDays={period.defaultDays} />
|
||||
</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} />
|
||||
<AreaChart data={data?.items ?? []} />
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
|
||||
@@ -68,7 +67,7 @@ export function PublicationGraphChart() {
|
||||
<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} />
|
||||
<Status icons={false} percentage={true} value={data?.meta?.delta} />
|
||||
<span className="text-muted-foreground">period</span>
|
||||
{data?.meta?.previous === 0 && data?.meta?.current === 0 && (
|
||||
<span className="text-muted-foreground">(no articles yet)</span>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck
|
||||
"use client";
|
||||
|
||||
import {
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Bar, BarChart as BaseBarChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import { formatDate } from "#dashboard/utils/utils";
|
||||
|
||||
type BarChartProps = {
|
||||
data: unknown;
|
||||
type BarChartProps<T> = {
|
||||
data: T[];
|
||||
};
|
||||
|
||||
export function BarChart({ data }: BarChartProps) {
|
||||
export function BarChart<T>({ data }: BarChartProps<T>) {
|
||||
return (
|
||||
<BaseBarChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
@@ -25,8 +25,6 @@ const DEFAULT_PERIOD_OPTIONS = [
|
||||
{ label: "Last 12 months", value: 365 },
|
||||
] as const;
|
||||
|
||||
type DateInput = number | Date | null | undefined;
|
||||
|
||||
const createRangeFromDays = (days: number): DateRange => {
|
||||
const end = new Date();
|
||||
|
||||
@@ -75,7 +73,7 @@ export function useChartPeriodFilter(options: ChartPeriodFilterOptions = {}) {
|
||||
return undefined;
|
||||
}, [from, to]);
|
||||
|
||||
const range = useMemo(() => {
|
||||
const calendarRange: DateRange | undefined = useMemo(() => {
|
||||
if (from && to) {
|
||||
return { from, to };
|
||||
}
|
||||
@@ -83,7 +81,10 @@ export function useChartPeriodFilter(options: ChartPeriodFilterOptions = {}) {
|
||||
return defaultRange;
|
||||
}, [defaultRange, from, to]);
|
||||
|
||||
const range = useMemo(() => formatDomainDateRange(calendarRange), [calendarRange]);
|
||||
|
||||
return {
|
||||
calendarRange,
|
||||
defaultDays,
|
||||
keys: { fromKey, toKey },
|
||||
range,
|
||||
@@ -118,19 +119,22 @@ export function ChartPeriodPicker({
|
||||
paramKey = "chartPeriod",
|
||||
disabled,
|
||||
}: 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 selectValue = useMemo(() => {
|
||||
if (!range?.from || !range?.to) {
|
||||
if (!calendarRange?.from || !calendarRange?.to) {
|
||||
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);
|
||||
|
||||
return match ? String(match.value) : "custom";
|
||||
}, [options, range]);
|
||||
}, [calendarRange, options]);
|
||||
|
||||
const handlePresetChange = (value: string) => {
|
||||
if (value === "custom") {
|
||||
@@ -160,7 +164,7 @@ export function ChartPeriodPicker({
|
||||
};
|
||||
|
||||
const displayLabel =
|
||||
formatDateRange(range) ??
|
||||
formatDateRange(calendarRange) ??
|
||||
options.find((option) => String(option.value) === selectValue)?.label ??
|
||||
"Select range";
|
||||
|
||||
@@ -196,7 +200,7 @@ export function ChartPeriodPicker({
|
||||
mode="range"
|
||||
numberOfMonths={2}
|
||||
onSelect={handleCalendarSelect}
|
||||
selected={(selectedRange ?? range) as DateRange | undefined}
|
||||
selected={(selectedRange ?? calendarRange) as DateRange | undefined}
|
||||
/>
|
||||
|
||||
<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;
|
||||
|
||||
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";
|
||||
|
||||
import {
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 { useTRPC } from "#dashboard/trpc/client";
|
||||
import { getColorFromName } from "#dashboard/utils/categories";
|
||||
@@ -19,6 +19,8 @@ type Props = {
|
||||
sourceId: string;
|
||||
};
|
||||
|
||||
type CategoryShare = RouterOutputs["sources"]["getCategoryShares"]["items"][number];
|
||||
|
||||
export function CategorySharesChart({ sourceId }: Props) {
|
||||
const trpc = useTRPC();
|
||||
const { limit } = useChartLimitFilter();
|
||||
@@ -29,11 +31,12 @@ export function CategorySharesChart({ sourceId }: Props) {
|
||||
limit,
|
||||
}),
|
||||
);
|
||||
const items: CategoryShare[] = data?.items || [];
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
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>
|
||||
<CardDescription>showing top {limit} categories for this source</CardDescription>
|
||||
</div>
|
||||
<ChartLimitToggle paramKey={`categoryLimit-${sourceId}`} />
|
||||
<ChartLimitToggle />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="-ml-1 h-20">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -37,7 +36,7 @@ export function PublicationGraphChart({ sourceId }: Props) {
|
||||
const period = useChartPeriodFilter();
|
||||
|
||||
const { data } = useQuery(
|
||||
trpc.sources.getPublicationGraph.queryOptions({
|
||||
trpc.sources.getPublications.queryOptions({
|
||||
id: sourceId,
|
||||
range: period.range,
|
||||
}),
|
||||
@@ -50,11 +49,11 @@ export function PublicationGraphChart({ sourceId }: Props) {
|
||||
<CardTitle>Publication Graph</CardTitle>
|
||||
<CardDescription>Showing total crawled articles for the selected period</CardDescription>
|
||||
</div>
|
||||
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey={`sourcePeriod-${sourceId}`} />
|
||||
<ChartPeriodPicker defaultDays={period.defaultDays} />
|
||||
</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} />
|
||||
<AreaChart data={data?.items ?? []} />
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
|
||||
import { updateSourceSchema } from "@basango/domain/models/sources";
|
||||
import { updateSourceSchema } from "@basango/domain/models";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createSourceSchema } from "@basango/domain/models/sources";
|
||||
import { createSourceSchema } from "@basango/domain/models";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Source } from "@basango/domain/models/sources";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
} from "@basango/ui/components/chart";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { RouterOutputs } from "#api/trpc/routers/_app";
|
||||
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
||||
|
||||
const chartConfig = {
|
||||
@@ -29,7 +29,11 @@ const chartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function SourceCard({ source }: { source: Source }) {
|
||||
type Props = {
|
||||
source: RouterOutputs["sources"]["list"][number];
|
||||
};
|
||||
|
||||
export function SourceCard({ source }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"#api/*": ["../api/src/*"],
|
||||
"#dashboard/*": ["./src/*"],
|
||||
"#db/*": ["../../packages/db/src/*"],
|
||||
"#domain/*": ["../../packages/domain/src/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
@@ -35,9 +35,11 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "expo start --android",
|
||||
"clean": "rm -rf .expo node_modules",
|
||||
"ios": "expo start --ios",
|
||||
"lint": "expo lint",
|
||||
"start": "expo start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"#mobile/*": ["./*"]
|
||||
"#mobile/*": ["./src/*"]
|
||||
},
|
||||
"strict": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user