diff --git a/apps/api/config/cors.json b/apps/api/config/cors.json index 0929a4c..8be0c02 100644 --- a/apps/api/config/cors.json +++ b/apps/api/config/cors.json @@ -11,7 +11,6 @@ ], "allowMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], "exposeHeaders": ["Content-Length"], - "maxAge": 86400, - "origin": "%env(BASANGO_API_ALLOWED_ORIGINS)%" + "maxAge": 86400 } } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index d57128b..f2352ca 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -11,7 +11,10 @@ const ServerConfigurationSchema = z.object({ allowMethods: z.array(z.string()).optional(), exposeHeaders: z.array(z.string()).optional(), maxAge: z.number().int().min(0).optional(), - origin: z.string(), //z.array(z.string()).default([]), + origin: z + .array(z.string()) + .optional() + .default(["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"]), }), server: z.object({ host: z.string().default("localhost"), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d87bdc8..0b695a1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -22,7 +22,7 @@ app.use( allowMethods: config.cors.allowMethods, exposeHeaders: config.cors.exposeHeaders, maxAge: config.cors.maxAge, - origin: config.cors.origin, + origin: ["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"], }), ); diff --git a/apps/api/src/schemas/sources.ts b/apps/api/src/schemas/sources.ts index abbd355..56d8de6 100644 --- a/apps/api/src/schemas/sources.ts +++ b/apps/api/src/schemas/sources.ts @@ -55,6 +55,43 @@ export const getSourceSchema = z.object({ id: idSchema, }); +export const getSourcePublicationGraphSchema = z.object({ + days: z + .number() + .optional() + .openapi({ + default: 60, + description: "", + example: 60, + }) + .openapi({ + description: "The number of days to include in the publication graph.", + }), + id: idSchema, + range: z + .object({ + from: z.date().openapi({ + description: "The start date of the range.", + }), + to: z.date().openapi({ + description: "The end date of the range.", + }), + }) + .optional() + .openapi({ + description: "The date range for the publication graph.", + }), +}); + +export const getSourceCategorySharesSchema = z.object({ + id: idSchema, + limit: z.number().int().min(1).max(100).optional().openapi({ + default: 10, + description: "The maximum number of categories to return.", + example: 10, + }), +}); + export const updateSourceSchema = z.object({ credibility: credibilitySchema.optional(), description: createSourceSchema.shape.description, diff --git a/apps/api/src/trpc/routers/sources.ts b/apps/api/src/trpc/routers/sources.ts index 90c5326..654e937 100644 --- a/apps/api/src/trpc/routers/sources.ts +++ b/apps/api/src/trpc/routers/sources.ts @@ -7,7 +7,13 @@ import { updateSource, } from "@basango/db/queries"; -import { createSourceSchema, getSourceSchema, updateSourceSchema } from "#api/schemas/sources"; +import { + createSourceSchema, + getSourceCategorySharesSchema, + getSourcePublicationGraphSchema, + getSourceSchema, + updateSourceSchema, +} from "#api/schemas/sources"; import { createTRPCRouter, protectedProcedure } from "#api/trpc/init"; export const sourcesRouter = createTRPCRouter({ @@ -21,13 +27,17 @@ export const sourcesRouter = createTRPCRouter({ return getSourceById(ctx.db, input.id); }), - getCategoryShares: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => { - return getSourceCategoryShares(ctx.db, input.id); - }), + getCategoryShares: protectedProcedure + .input(getSourceCategorySharesSchema) + .query(async ({ ctx, input }) => { + return getSourceCategoryShares(ctx.db, input); + }), - getPublicationGraph: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => { - return getSourcePublicationGraph(ctx.db, input.id); - }), + getPublicationGraph: protectedProcedure + .input(getSourcePublicationGraphSchema) + .query(async ({ ctx, input }) => { + return getSourcePublicationGraph(ctx.db, input); + }), update: protectedProcedure.input(updateSourceSchema).mutation(async ({ ctx, input }) => { return updateSource(ctx.db, input); diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/[id]/page.tsx b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/[id]/page.tsx new file mode 100644 index 0000000..6f05550 --- /dev/null +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/[id]/page.tsx @@ -0,0 +1,51 @@ +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 { PageLayout } from "#dashboard/components/shell/page-layout"; +import { HydrateClient, batchPrefetch, getQueryClient, trpc } from "#dashboard/trpc/server"; + +export const metadata: Metadata = { + title: "Source Details | Basango Dashboard", +}; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const queryClient = getQueryClient(); + + batchPrefetch([ + trpc.sources.getById.queryOptions({ id }), + trpc.sources.getCategoryShares.queryOptions({ id }), + trpc.sources.getPublicationGraph.queryOptions({ id }), + ]); + + const source = await queryClient.fetchQuery(trpc.sources.getById.queryOptions({ id })); + + return ( + + + + + Overview + Articles + + + + + + +
+
+
+
+
+
+
+
+ + + + + ); +} diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/page.tsx b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/page.tsx index af2a7fb..22671c6 100644 --- a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/page.tsx +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/page.tsx @@ -1,8 +1,9 @@ import { RouterOutputs } from "@basango/api/trpc/routers/_app"; import { Metadata } from "next"; +import Link from "next/link"; import { PageLayout } from "#dashboard/components/shell/page-layout"; -import { SourceCard } from "#dashboard/components/source-card"; +import { SourceCard } from "#dashboard/components/widgets/source-card"; import { HydrateClient, getQueryClient, prefetch, trpc } from "#dashboard/trpc/server"; export const metadata: Metadata = { @@ -22,7 +23,9 @@ export default async function Page() {
{sources.map((source: SourceDetails) => ( - + + + ))}
diff --git a/apps/dashboard/src/components/charts/source-category-shares-chart.tsx b/apps/dashboard/src/components/charts/source-category-shares-chart.tsx new file mode 100644 index 0000000..e676074 --- /dev/null +++ b/apps/dashboard/src/components/charts/source-category-shares-chart.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + 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 { useTRPC } from "#dashboard/trpc/client"; +import { getColorFromName } from "#dashboard/utils/categories"; + +type Props = { + sourceId: string; +}; + +export function SourceCategorySharesChart({ sourceId }: Props) { + const trpc = useTRPC(); + const [limit, setLimit] = useState(10); + + const { data } = useQuery( + trpc.sources.getCategoryShares.queryOptions({ + id: sourceId, + limit: limit, + }), + ); + const items = data?.items ?? []; + + const chartData = [ + { + name: "Total", + ...Object.fromEntries(items.map((item) => [item.category, item.count])), + }, + ]; + + const barData = items.map((item) => ({ + fill: getColorFromName(item.category), + name: item.category, + })); + + return ( + + +
+ Category Shares + showing top {limit} categories for this source +
+ setLimit(Number(v))} + type="single" + value={String(limit)} + variant="outline" + > + Top 10 + Top 20 + Top 50 + +
+ +
+ + + + + + {barData.map((entry, index) => ( + + ))} + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/charts/source-publication-graph-chart.tsx b/apps/dashboard/src/components/charts/source-publication-graph-chart.tsx new file mode 100644 index 0000000..ae147cf --- /dev/null +++ b/apps/dashboard/src/components/charts/source-publication-graph-chart.tsx @@ -0,0 +1,109 @@ +"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 ( + + +
+ Publication Graph + + Showing total crawled articles for the last {timeRange} days + +
+ +
+ + + + + formatDate(new Date(value).toISOString())} + tickLine={false} + tickMargin={8} + /> + } cursor={false} /> + + + + +
+ ); +} diff --git a/apps/dashboard/src/components/source-card.tsx b/apps/dashboard/src/components/widgets/source-card.tsx similarity index 86% rename from apps/dashboard/src/components/source-card.tsx rename to apps/dashboard/src/components/widgets/source-card.tsx index d3909c9..2a1657d 100644 --- a/apps/dashboard/src/components/source-card.tsx +++ b/apps/dashboard/src/components/widgets/source-card.tsx @@ -16,7 +16,7 @@ import { } from "@basango/ui/components/chart"; import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; -import { formatNumber } from "#dashboard/utils/utils"; +import { formatDate, formatNumber } from "#dashboard/utils/utils"; const chartConfig = { count: { @@ -61,13 +61,7 @@ export function SourceCard({ source }: { source: SourceDetails }) { axisLine={false} dataKey="date" minTickGap={32} - tickFormatter={(value) => { - const date = new Date(value); - return date.toLocaleDateString("en-US", { - day: "numeric", - month: "short", - }); - }} + tickFormatter={(value) => formatDate(new Date(value).toISOString())} tickLine={false} tickMargin={8} /> diff --git a/apps/dashboard/src/trpc/server.tsx b/apps/dashboard/src/trpc/server.tsx index 720ddff..9509152 100644 --- a/apps/dashboard/src/trpc/server.tsx +++ b/apps/dashboard/src/trpc/server.tsx @@ -54,3 +54,15 @@ export function prefetch>>(queryOptio void queryClient.prefetchQuery(queryOptions); } } + +export function batchPrefetch>>(queryOptionsArray: T[]) { + const queryClient = getQueryClient(); + + for (const queryOptions of queryOptionsArray) { + if (queryOptions.queryKey[1]?.type === "infinite") { + void queryClient.prefetchInfiniteQuery(queryOptions as any); + } else { + void queryClient.prefetchQuery(queryOptions); + } + } +} diff --git a/packages/db/src/constants.ts b/packages/db/src/constants.ts index bcb79ee..a263e28 100644 --- a/packages/db/src/constants.ts +++ b/packages/db/src/constants.ts @@ -8,7 +8,7 @@ export const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/"; * Number of days to include in the publication graph for sources. * This defines the time range for which publication data is aggregated and displayed. */ -export const PUBLICATION_GRAPH_DAYS = 60; +export const PUBLICATION_GRAPH_DAYS = 30; /** * Maximum number of category shares to return for a source. diff --git a/packages/db/src/queries/sources.ts b/packages/db/src/queries/sources.ts index d0a7d42..c09252d 100644 --- a/packages/db/src/queries/sources.ts +++ b/packages/db/src/queries/sources.ts @@ -16,7 +16,7 @@ export async function getSources(db: Database) { rows.map(async (row) => ({ ...row, articles: await countArticlesBySourceId(db, row.id), - publicationGraph: await getSourcePublicationGraph(db, row.id), + publicationGraph: await getSourcePublicationGraph(db, { id: row.id }), })), ); } @@ -133,13 +133,21 @@ export type CategoryShares = { total: number; }; +export type GetSourcePublicationGraphParams = { + id: string; + days?: number; + range?: { + from: Date; + to: Date; + }; +}; + export async function getSourcePublicationGraph( db: Database, - id: string, - days: number = PUBLICATION_GRAPH_DAYS, + params: GetSourcePublicationGraphParams, ): Promise { const endDate = endOfDay(new Date()); - const startDate = startOfDay(subDays(endDate, days - 1)); + const startDate = startOfDay(subDays(endDate, params.days ?? PUBLICATION_GRAPH_DAYS - 1)); const data = await db.execute(sql` WITH bounds AS ( @@ -161,7 +169,7 @@ export async function getSourcePublicationGraph( a.published_at::date AS d, COUNT(*)::int AS c FROM article a, bounds b - WHERE a.source_id = ${id}::uuid + WHERE a.source_id = ${params.id}::uuid AND a.published_at >= timezone(${TIMEZONE}, b.start_ts) AND a.published_at <= timezone(${TIMEZONE}, b.end_ts) GROUP BY 1 @@ -177,7 +185,15 @@ export async function getSourcePublicationGraph( return { items: data.rows, total: data.rows.length }; } -export async function getSourceCategoryShares(db: Database, id: string): Promise { +export type GetSourceCategorySharesParams = { + id: string; + limit?: number; +}; + +export async function getSourceCategoryShares( + db: Database, + params: GetSourceCategorySharesParams, +): Promise { const data = await db.execute(sql` SELECT cat AS category, @@ -187,12 +203,12 @@ export async function getSourceCategoryShares(db: Database, id: string): Promise SELECT NULLIF(BTRIM(c), '') AS cat FROM ${articles} CROSS JOIN LATERAL UNNEST(COALESCE(${articles.categories}, ARRAY[]::text[])) AS c - WHERE ${articles.sourceId} = ${id} + WHERE ${articles.sourceId} = ${params.id} ) t WHERE cat IS NOT NULL GROUP BY cat ORDER BY count DESC - LIMIT ${CATEGORY_SHARES_LIMIT} + LIMIT ${params.limit ?? CATEGORY_SHARES_LIMIT} `); return { items: data.rows, total: data.rowCount ?? 0 }; diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 9848899..f567f22 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -124,4 +124,9 @@ body { @apply bg-background text-foreground; } + + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } }