diff --git a/app/content/page.tsx b/app/content/page.tsx new file mode 100644 index 0000000..9eaf1a8 --- /dev/null +++ b/app/content/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import ContentSlackPage from "@/components/ContentSlack/ContentSlackPage"; + +export const metadata: Metadata = { + title: "Content Agent — Recoup Admin", +}; + +export default function Page() { + return ; +} diff --git a/components/ContentSlack/ContentSlackPage.tsx b/components/ContentSlack/ContentSlackPage.tsx new file mode 100644 index 0000000..07e251d --- /dev/null +++ b/components/ContentSlack/ContentSlackPage.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb"; +import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink"; +import { useContentSlackTags } from "@/hooks/useContentSlackTags"; +import ContentSlackTable from "@/components/ContentSlack/ContentSlackTable"; +import ContentSlackStats from "@/components/ContentSlack/ContentSlackStats"; +import PeriodSelector from "@/components/Admin/PeriodSelector"; +import AdminLineChart from "@/components/Admin/AdminLineChart"; +import TableSkeleton from "@/components/Sandboxes/TableSkeleton"; +import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton"; +import { getTagsByDate } from "@/lib/coding-agent/getTagsByDate"; +import type { AdminPeriod } from "@/types/admin"; + +export default function ContentSlackPage() { + const [period, setPeriod] = useState("all"); + const { data, isLoading, error } = useContentSlackTags(period); + + const tagsByDate = data + ? getTagsByDate( + data.tags.map((t) => ({ + ...t, + pull_requests: t.video_links, + })), + ) + : []; + + return ( + + + + + + Content Agent Usage + + + Slack tags to the Content Agent, grouped by time period. + + + + + + + + {data && } + + + {isLoading && ( + <> + + + > + )} + + {error && ( + + {error instanceof Error ? error.message : "Failed to load Content Agent tags"} + + )} + + {!isLoading && !error && data && data.tags.length === 0 && ( + + No tags found for this period. + + )} + + {!isLoading && !error && data && data.tags.length > 0 && ( + <> + ({ date: d.date, count: d.count }))} + label="Tags" + secondLine={{ + data: tagsByDate.map((d) => ({ date: d.date, count: d.pull_request_count })), + label: "Tags with Videos", + }} + /> + + > + )} + + ); +} diff --git a/components/ContentSlack/ContentSlackStats.tsx b/components/ContentSlack/ContentSlackStats.tsx new file mode 100644 index 0000000..951d516 --- /dev/null +++ b/components/ContentSlack/ContentSlackStats.tsx @@ -0,0 +1,21 @@ +import type { ContentSlackResponse } from "@/types/contentSlack"; + +interface ContentSlackStatsProps { + data: ContentSlackResponse; +} + +export default function ContentSlackStats({ data }: ContentSlackStatsProps) { + return ( + + + {data.total} tags + + + {data.total_videos} videos + + + {data.tags_with_videos} with videos + + + ); +} diff --git a/components/ContentSlack/ContentSlackTable.tsx b/components/ContentSlack/ContentSlackTable.tsx new file mode 100644 index 0000000..a6f37e8 --- /dev/null +++ b/components/ContentSlack/ContentSlackTable.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type SortingState, +} from "@tanstack/react-table"; +import { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { contentSlackColumns } from "@/components/ContentSlack/contentSlackColumns"; +import type { ContentSlackTag } from "@/types/contentSlack"; + +interface ContentSlackTableProps { + tags: ContentSlackTag[]; +} + +export default function ContentSlackTable({ tags }: ContentSlackTableProps) { + const [sorting, setSorting] = useState([ + { id: "timestamp", desc: true }, + ]); + + const table = useReactTable({ + data: tags, + columns: contentSlackColumns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + + + + ); +} diff --git a/components/ContentSlack/contentSlackColumns.tsx b/components/ContentSlack/contentSlackColumns.tsx new file mode 100644 index 0000000..9666501 --- /dev/null +++ b/components/ContentSlack/contentSlackColumns.tsx @@ -0,0 +1,76 @@ +import { type ColumnDef } from "@tanstack/react-table"; +import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader"; +import type { ContentSlackTag } from "@/types/contentSlack"; + +export const contentSlackColumns: ColumnDef[] = [ + { + id: "user_name", + accessorKey: "user_name", + header: "User", + cell: ({ row }) => { + const tag = row.original; + return ( + + {tag.user_avatar && ( + + )} + {tag.user_name} + + ); + }, + }, + { + id: "timestamp", + accessorKey: "timestamp", + header: ({ column }) => , + cell: ({ getValue }) => + new Date(getValue()).toLocaleString(), + sortingFn: "datetime", + }, + { + id: "prompt", + accessorKey: "prompt", + header: "Prompt", + cell: ({ getValue }) => { + const text = getValue(); + return ( + + {text} + + ); + }, + }, + { + id: "video_links", + accessorFn: (row) => row.video_links.length, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const links = row.original.video_links; + if (links.length === 0) { + return —; + } + return ( + + {links.map((link, i) => ( + + {link} + + ))} + + ); + }, + sortingFn: "basic", + }, +]; diff --git a/components/Home/AdminDashboard.tsx b/components/Home/AdminDashboard.tsx index 1fbc8a3..3633e68 100644 --- a/components/Home/AdminDashboard.tsx +++ b/components/Home/AdminDashboard.tsx @@ -13,6 +13,7 @@ export default function AdminDashboard() { + ); diff --git a/hooks/useContentSlackTags.ts b/hooks/useContentSlackTags.ts new file mode 100644 index 0000000..fbab41f --- /dev/null +++ b/hooks/useContentSlackTags.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { fetchContentSlackTags } from "@/lib/recoup/fetchContentSlackTags"; +import type { AdminPeriod } from "@/types/admin"; + +export function useContentSlackTags(period: AdminPeriod) { + const { ready, authenticated, getAccessToken } = usePrivy(); + + return useQuery({ + queryKey: ["admin", "content", "slack", period], + queryFn: async () => { + const token = await getAccessToken(); + if (!token) throw new Error("Not authenticated"); + return fetchContentSlackTags(token, period); + }, + enabled: ready && authenticated, + }); +} diff --git a/lib/recoup/fetchContentSlackTags.ts b/lib/recoup/fetchContentSlackTags.ts new file mode 100644 index 0000000..b7aeeaf --- /dev/null +++ b/lib/recoup/fetchContentSlackTags.ts @@ -0,0 +1,22 @@ +import { API_BASE_URL } from "@/lib/consts"; +import type { AdminPeriod } from "@/types/admin"; +import type { ContentSlackResponse } from "@/types/contentSlack"; + +export async function fetchContentSlackTags( + accessToken: string, + period: AdminPeriod, +): Promise { + const url = new URL(`${API_BASE_URL}/api/admins/content/slack`); + url.searchParams.set("period", period); + + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); + } + + return res.json(); +} diff --git a/types/contentSlack.ts b/types/contentSlack.ts new file mode 100644 index 0000000..8879b3c --- /dev/null +++ b/types/contentSlack.ts @@ -0,0 +1,18 @@ +export type ContentSlackTag = { + user_id: string; + user_name: string; + user_avatar: string | null; + prompt: string; + timestamp: string; + channel_id: string; + channel_name: string; + video_links: string[]; +}; + +export type ContentSlackResponse = { + status: "success" | "error"; + total: number; + total_videos: number; + tags_with_videos: number; + tags: ContentSlackTag[]; +};
+ Slack tags to the Content Agent, grouped by time period. +