From cd20064f3fb4311b95c1d73e5ba6e09992255650 Mon Sep 17 00:00:00 2001 From: Sr Dev Date: Thu, 26 Mar 2026 12:42:55 +0000 Subject: [PATCH 1/3] feat: add Content Agent usage dashboard page - New /content route with table, bar chart, and period selector - Integrates with GET /api/admins/content/slack endpoint - Displays user, timestamp, prompt, and video link columns - Follows existing admin page patterns (Privy Logins) - Adds nav link to admin home dashboard Co-Authored-By: Paperclip --- app/content/page.tsx | 10 +++ components/ContentSlack/ContentSlackChart.tsx | 71 +++++++++++++++++ components/ContentSlack/ContentSlackPage.tsx | 66 ++++++++++++++++ .../ContentSlackPeriodSelector.tsx | 36 +++++++++ components/ContentSlack/ContentSlackStats.tsx | 21 +++++ components/ContentSlack/ContentSlackTable.tsx | 78 +++++++++++++++++++ .../ContentSlack/contentSlackColumns.tsx | 61 +++++++++++++++ components/Home/AdminDashboard.tsx | 1 + hooks/useContentSlackTags.ts | 20 +++++ lib/contentSlack/getTagsByDate.ts | 19 +++++ lib/recoup/fetchContentSlackTags.ts | 24 ++++++ types/contentSlack.ts | 20 +++++ 12 files changed, 427 insertions(+) create mode 100644 app/content/page.tsx create mode 100644 components/ContentSlack/ContentSlackChart.tsx create mode 100644 components/ContentSlack/ContentSlackPage.tsx create mode 100644 components/ContentSlack/ContentSlackPeriodSelector.tsx create mode 100644 components/ContentSlack/ContentSlackStats.tsx create mode 100644 components/ContentSlack/ContentSlackTable.tsx create mode 100644 components/ContentSlack/contentSlackColumns.tsx create mode 100644 hooks/useContentSlackTags.ts create mode 100644 lib/contentSlack/getTagsByDate.ts create mode 100644 lib/recoup/fetchContentSlackTags.ts create mode 100644 types/contentSlack.ts 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/ContentSlackChart.tsx b/components/ContentSlack/ContentSlackChart.tsx new file mode 100644 index 0000000..c5e6eaa --- /dev/null +++ b/components/ContentSlack/ContentSlackChart.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import type { ContentSlackTag } from "@/types/contentSlack"; +import { getTagsByDate } from "@/lib/contentSlack/getTagsByDate"; + +const chartConfig = { + count: { + label: "Tags", + color: "#345A5D", + }, +} satisfies ChartConfig; + +interface ContentSlackChartProps { + tags: ContentSlackTag[]; +} + +export default function ContentSlackChart({ tags }: ContentSlackChartProps) { + const data = getTagsByDate(tags); + + if (data.length === 0) return null; + + return ( +
+

+ Tags Over Time +

+ + + + { + const d = new Date(value + "T00:00:00"); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }} + /> + + { + const d = new Date(String(value) + "T00:00:00"); + return d.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }); + }} + /> + } + /> + + + +
+ ); +} diff --git a/components/ContentSlack/ContentSlackPage.tsx b/components/ContentSlack/ContentSlackPage.tsx new file mode 100644 index 0000000..d488723 --- /dev/null +++ b/components/ContentSlack/ContentSlackPage.tsx @@ -0,0 +1,66 @@ +"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 ContentSlackPeriodSelector from "@/components/ContentSlack/ContentSlackPeriodSelector"; +import ContentSlackStats from "@/components/ContentSlack/ContentSlackStats"; +import ContentSlackChart from "@/components/ContentSlack/ContentSlackChart"; +import TableSkeleton from "@/components/Sandboxes/TableSkeleton"; +import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton"; +import type { ContentSlackPeriod } from "@/types/contentSlack"; + +export default function ContentSlackPage() { + const [period, setPeriod] = useState("all"); + const { data, isLoading, error } = useContentSlackTags(period); + + 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 && ( + <> + + + + )} +
+ ); +} diff --git a/components/ContentSlack/ContentSlackPeriodSelector.tsx b/components/ContentSlack/ContentSlackPeriodSelector.tsx new file mode 100644 index 0000000..1ebb2f4 --- /dev/null +++ b/components/ContentSlack/ContentSlackPeriodSelector.tsx @@ -0,0 +1,36 @@ +import type { ContentSlackPeriod } from "@/types/contentSlack"; + +const PERIODS: { value: ContentSlackPeriod; label: string }[] = [ + { value: "all", label: "All Time" }, + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, +]; + +interface ContentSlackPeriodSelectorProps { + period: ContentSlackPeriod; + onPeriodChange: (period: ContentSlackPeriod) => void; +} + +export default function ContentSlackPeriodSelector({ + period, + onPeriodChange, +}: ContentSlackPeriodSelectorProps) { + return ( +
+ {PERIODS.map(({ value, label }) => ( + + ))} +
+ ); +} diff --git a/components/ContentSlack/ContentSlackStats.tsx b/components/ContentSlack/ContentSlackStats.tsx new file mode 100644 index 0000000..ebb0df2 --- /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_video_links} video links + + + {data.tags_with_video_links} 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..919f89b --- /dev/null +++ b/components/ContentSlack/contentSlackColumns.tsx @@ -0,0 +1,61 @@ +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", + }, + { + 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 2fbc5ed..207effd 100644 --- a/components/Home/AdminDashboard.tsx +++ b/components/Home/AdminDashboard.tsx @@ -12,6 +12,7 @@ export default function AdminDashboard() { + ); diff --git a/hooks/useContentSlackTags.ts b/hooks/useContentSlackTags.ts new file mode 100644 index 0000000..2f661f5 --- /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 { ContentSlackPeriod } from "@/types/contentSlack"; + +export function useContentSlackTags(period: ContentSlackPeriod) { + 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/contentSlack/getTagsByDate.ts b/lib/contentSlack/getTagsByDate.ts new file mode 100644 index 0000000..9d155dc --- /dev/null +++ b/lib/contentSlack/getTagsByDate.ts @@ -0,0 +1,19 @@ +import type { ContentSlackTag } from "@/types/contentSlack"; + +export type TagsByDatePoint = { + date: string; + count: number; +}; + +export function getTagsByDate(tags: ContentSlackTag[]): TagsByDatePoint[] { + const counts = new Map(); + + for (const tag of tags) { + const date = new Date(tag.timestamp).toISOString().split("T")[0]; + counts.set(date, (counts.get(date) ?? 0) + 1); + } + + return Array.from(counts.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, count]) => ({ date, count })); +} diff --git a/lib/recoup/fetchContentSlackTags.ts b/lib/recoup/fetchContentSlackTags.ts new file mode 100644 index 0000000..819e235 --- /dev/null +++ b/lib/recoup/fetchContentSlackTags.ts @@ -0,0 +1,24 @@ +import { API_BASE_URL } from "@/lib/consts"; +import type { + ContentSlackPeriod, + ContentSlackResponse, +} from "@/types/contentSlack"; + +export async function fetchContentSlackTags( + accessToken: string, + period: ContentSlackPeriod, +): 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..7abb890 --- /dev/null +++ b/types/contentSlack.ts @@ -0,0 +1,20 @@ +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 ContentSlackPeriod = "all" | "daily" | "weekly" | "monthly"; + +export type ContentSlackResponse = { + status: "success" | "error"; + total: number; + total_video_links: number; + tags_with_video_links: number; + tags: ContentSlackTag[]; +}; From b156701301eb8cd12262769f73068f8ae05142c4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 26 Mar 2026 15:14:36 -0500 Subject: [PATCH 2/3] refactor: DRY content page by reusing shared admin components Replace ContentSlackChart, ContentSlackPeriodSelector, and contentSlack/getTagsByDate with shared AdminLineChart, PeriodSelector, and coding-agent/getTagsByDate. Use AdminPeriod type throughout. Co-Authored-By: Claude Opus 4.6 --- components/ContentSlack/ContentSlackChart.tsx | 71 ------------------- components/ContentSlack/ContentSlackPage.tsx | 30 ++++++-- .../ContentSlackPeriodSelector.tsx | 36 ---------- hooks/useContentSlackTags.ts | 4 +- lib/contentSlack/getTagsByDate.ts | 19 ----- lib/recoup/fetchContentSlackTags.ts | 8 +-- types/contentSlack.ts | 2 - 7 files changed, 29 insertions(+), 141 deletions(-) delete mode 100644 components/ContentSlack/ContentSlackChart.tsx delete mode 100644 components/ContentSlack/ContentSlackPeriodSelector.tsx delete mode 100644 lib/contentSlack/getTagsByDate.ts diff --git a/components/ContentSlack/ContentSlackChart.tsx b/components/ContentSlack/ContentSlackChart.tsx deleted file mode 100644 index c5e6eaa..0000000 --- a/components/ContentSlack/ContentSlackChart.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; -import type { ContentSlackTag } from "@/types/contentSlack"; -import { getTagsByDate } from "@/lib/contentSlack/getTagsByDate"; - -const chartConfig = { - count: { - label: "Tags", - color: "#345A5D", - }, -} satisfies ChartConfig; - -interface ContentSlackChartProps { - tags: ContentSlackTag[]; -} - -export default function ContentSlackChart({ tags }: ContentSlackChartProps) { - const data = getTagsByDate(tags); - - if (data.length === 0) return null; - - return ( -
-

- Tags Over Time -

- - - - { - const d = new Date(value + "T00:00:00"); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - }} - /> - - { - const d = new Date(String(value) + "T00:00:00"); - return d.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }); - }} - /> - } - /> - - - -
- ); -} diff --git a/components/ContentSlack/ContentSlackPage.tsx b/components/ContentSlack/ContentSlackPage.tsx index d488723..07e251d 100644 --- a/components/ContentSlack/ContentSlackPage.tsx +++ b/components/ContentSlack/ContentSlackPage.tsx @@ -5,17 +5,27 @@ import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb"; import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink"; import { useContentSlackTags } from "@/hooks/useContentSlackTags"; import ContentSlackTable from "@/components/ContentSlack/ContentSlackTable"; -import ContentSlackPeriodSelector from "@/components/ContentSlack/ContentSlackPeriodSelector"; import ContentSlackStats from "@/components/ContentSlack/ContentSlackStats"; -import ContentSlackChart from "@/components/ContentSlack/ContentSlackChart"; +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 type { ContentSlackPeriod } from "@/types/contentSlack"; +import { getTagsByDate } from "@/lib/coding-agent/getTagsByDate"; +import type { AdminPeriod } from "@/types/admin"; export default function ContentSlackPage() { - const [period, setPeriod] = useState("all"); + 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 (
@@ -32,7 +42,7 @@ export default function ContentSlackPage() {
- + {data && }
@@ -57,7 +67,15 @@ export default function ContentSlackPage() { {!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/ContentSlackPeriodSelector.tsx b/components/ContentSlack/ContentSlackPeriodSelector.tsx deleted file mode 100644 index 1ebb2f4..0000000 --- a/components/ContentSlack/ContentSlackPeriodSelector.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { ContentSlackPeriod } from "@/types/contentSlack"; - -const PERIODS: { value: ContentSlackPeriod; label: string }[] = [ - { value: "all", label: "All Time" }, - { value: "daily", label: "Daily" }, - { value: "weekly", label: "Weekly" }, - { value: "monthly", label: "Monthly" }, -]; - -interface ContentSlackPeriodSelectorProps { - period: ContentSlackPeriod; - onPeriodChange: (period: ContentSlackPeriod) => void; -} - -export default function ContentSlackPeriodSelector({ - period, - onPeriodChange, -}: ContentSlackPeriodSelectorProps) { - return ( -
- {PERIODS.map(({ value, label }) => ( - - ))} -
- ); -} diff --git a/hooks/useContentSlackTags.ts b/hooks/useContentSlackTags.ts index 2f661f5..fbab41f 100644 --- a/hooks/useContentSlackTags.ts +++ b/hooks/useContentSlackTags.ts @@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { usePrivy } from "@privy-io/react-auth"; import { fetchContentSlackTags } from "@/lib/recoup/fetchContentSlackTags"; -import type { ContentSlackPeriod } from "@/types/contentSlack"; +import type { AdminPeriod } from "@/types/admin"; -export function useContentSlackTags(period: ContentSlackPeriod) { +export function useContentSlackTags(period: AdminPeriod) { const { ready, authenticated, getAccessToken } = usePrivy(); return useQuery({ diff --git a/lib/contentSlack/getTagsByDate.ts b/lib/contentSlack/getTagsByDate.ts deleted file mode 100644 index 9d155dc..0000000 --- a/lib/contentSlack/getTagsByDate.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ContentSlackTag } from "@/types/contentSlack"; - -export type TagsByDatePoint = { - date: string; - count: number; -}; - -export function getTagsByDate(tags: ContentSlackTag[]): TagsByDatePoint[] { - const counts = new Map(); - - for (const tag of tags) { - const date = new Date(tag.timestamp).toISOString().split("T")[0]; - counts.set(date, (counts.get(date) ?? 0) + 1); - } - - return Array.from(counts.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, count]) => ({ date, count })); -} diff --git a/lib/recoup/fetchContentSlackTags.ts b/lib/recoup/fetchContentSlackTags.ts index 819e235..b7aeeaf 100644 --- a/lib/recoup/fetchContentSlackTags.ts +++ b/lib/recoup/fetchContentSlackTags.ts @@ -1,12 +1,10 @@ import { API_BASE_URL } from "@/lib/consts"; -import type { - ContentSlackPeriod, - ContentSlackResponse, -} from "@/types/contentSlack"; +import type { AdminPeriod } from "@/types/admin"; +import type { ContentSlackResponse } from "@/types/contentSlack"; export async function fetchContentSlackTags( accessToken: string, - period: ContentSlackPeriod, + period: AdminPeriod, ): Promise { const url = new URL(`${API_BASE_URL}/api/admins/content/slack`); url.searchParams.set("period", period); diff --git a/types/contentSlack.ts b/types/contentSlack.ts index 3682372..8879b3c 100644 --- a/types/contentSlack.ts +++ b/types/contentSlack.ts @@ -9,8 +9,6 @@ export type ContentSlackTag = { video_links: string[]; }; -export type ContentSlackPeriod = "all" | "daily" | "weekly" | "monthly"; - export type ContentSlackResponse = { status: "success" | "error"; total: number; From ab6b63519ab7e47f16ce0ec3617d78e8c952d7c4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 26 Mar 2026 15:16:27 -0500 Subject: [PATCH 3/3] feat: show user avatar in content agent table Match the coding agent table by displaying the Slack profile picture next to the user name in the Content Agent table. Co-Authored-By: Claude Opus 4.6 --- components/ContentSlack/contentSlackColumns.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/components/ContentSlack/contentSlackColumns.tsx b/components/ContentSlack/contentSlackColumns.tsx index 919f89b..9666501 100644 --- a/components/ContentSlack/contentSlackColumns.tsx +++ b/components/ContentSlack/contentSlackColumns.tsx @@ -7,6 +7,21 @@ 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} + )} + {tag.user_name} +
+ ); + }, }, { id: "timestamp",