Skip to content

Commit b425199

Browse files
feat: Content Agent usage dashboard page (#24)
Add /content admin route with bar chart (tags over time), data table, and period selector. Integrates with GET /api/admins/content/slack endpoint.
1 parent 7939492 commit b425199

9 files changed

Lines changed: 330 additions & 0 deletions

File tree

app/content/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Metadata } from "next";
2+
import ContentSlackPage from "@/components/ContentSlack/ContentSlackPage";
3+
4+
export const metadata: Metadata = {
5+
title: "Content Agent — Recoup Admin",
6+
};
7+
8+
export default function Page() {
9+
return <ContentSlackPage />;
10+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
5+
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
6+
import { useContentSlackTags } from "@/hooks/useContentSlackTags";
7+
import ContentSlackTable from "@/components/ContentSlack/ContentSlackTable";
8+
import ContentSlackStats from "@/components/ContentSlack/ContentSlackStats";
9+
import PeriodSelector from "@/components/Admin/PeriodSelector";
10+
import AdminLineChart from "@/components/Admin/AdminLineChart";
11+
import TableSkeleton from "@/components/Sandboxes/TableSkeleton";
12+
import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton";
13+
import { getTagsByDate } from "@/lib/coding-agent/getTagsByDate";
14+
import type { AdminPeriod } from "@/types/admin";
15+
16+
export default function ContentSlackPage() {
17+
const [period, setPeriod] = useState<AdminPeriod>("all");
18+
const { data, isLoading, error } = useContentSlackTags(period);
19+
20+
const tagsByDate = data
21+
? getTagsByDate(
22+
data.tags.map((t) => ({
23+
...t,
24+
pull_requests: t.video_links,
25+
})),
26+
)
27+
: [];
28+
29+
return (
30+
<main className="mx-auto max-w-6xl px-4 py-10">
31+
<div className="mb-6 flex items-start justify-between">
32+
<div>
33+
<PageBreadcrumb current="Content Agent" />
34+
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
35+
Content Agent Usage
36+
</h1>
37+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
38+
Slack tags to the Content Agent, grouped by time period.
39+
</p>
40+
</div>
41+
<ApiDocsLink path="admins/content-slack-tags" />
42+
</div>
43+
44+
<div className="mb-6 flex items-center gap-4">
45+
<PeriodSelector period={period} onPeriodChange={setPeriod} />
46+
{data && <ContentSlackStats data={data} />}
47+
</div>
48+
49+
{isLoading && (
50+
<>
51+
<ChartSkeleton />
52+
<TableSkeleton columns={["User", "Timestamp", "Prompt", "Video Links"]} />
53+
</>
54+
)}
55+
56+
{error && (
57+
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
58+
{error instanceof Error ? error.message : "Failed to load Content Agent tags"}
59+
</div>
60+
)}
61+
62+
{!isLoading && !error && data && data.tags.length === 0 && (
63+
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
64+
No tags found for this period.
65+
</div>
66+
)}
67+
68+
{!isLoading && !error && data && data.tags.length > 0 && (
69+
<>
70+
<AdminLineChart
71+
title="Tags & Videos Over Time"
72+
data={tagsByDate.map((d) => ({ date: d.date, count: d.count }))}
73+
label="Tags"
74+
secondLine={{
75+
data: tagsByDate.map((d) => ({ date: d.date, count: d.pull_request_count })),
76+
label: "Tags with Videos",
77+
}}
78+
/>
79+
<ContentSlackTable tags={data.tags} />
80+
</>
81+
)}
82+
</main>
83+
);
84+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ContentSlackResponse } from "@/types/contentSlack";
2+
3+
interface ContentSlackStatsProps {
4+
data: ContentSlackResponse;
5+
}
6+
7+
export default function ContentSlackStats({ data }: ContentSlackStatsProps) {
8+
return (
9+
<div className="flex gap-4 text-sm text-gray-500 dark:text-gray-400">
10+
<span>
11+
<span className="font-semibold text-gray-900 dark:text-gray-100">{data.total}</span> tags
12+
</span>
13+
<span>
14+
<span className="font-semibold text-gray-900 dark:text-gray-100">{data.total_videos}</span> videos
15+
</span>
16+
<span>
17+
<span className="font-semibold text-gray-900 dark:text-gray-100">{data.tags_with_videos}</span> with videos
18+
</span>
19+
</div>
20+
);
21+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"use client";
2+
3+
import {
4+
flexRender,
5+
getCoreRowModel,
6+
getSortedRowModel,
7+
useReactTable,
8+
type SortingState,
9+
} from "@tanstack/react-table";
10+
import { useState } from "react";
11+
import {
12+
Table,
13+
TableBody,
14+
TableCell,
15+
TableHead,
16+
TableHeader,
17+
TableRow,
18+
} from "@/components/ui/table";
19+
import { contentSlackColumns } from "@/components/ContentSlack/contentSlackColumns";
20+
import type { ContentSlackTag } from "@/types/contentSlack";
21+
22+
interface ContentSlackTableProps {
23+
tags: ContentSlackTag[];
24+
}
25+
26+
export default function ContentSlackTable({ tags }: ContentSlackTableProps) {
27+
const [sorting, setSorting] = useState<SortingState>([
28+
{ id: "timestamp", desc: true },
29+
]);
30+
31+
const table = useReactTable({
32+
data: tags,
33+
columns: contentSlackColumns,
34+
state: { sorting },
35+
onSortingChange: setSorting,
36+
getCoreRowModel: getCoreRowModel(),
37+
getSortedRowModel: getSortedRowModel(),
38+
});
39+
40+
return (
41+
<div className="rounded-lg border">
42+
<Table>
43+
<TableHeader>
44+
{table.getHeaderGroups().map((headerGroup) => (
45+
<TableRow key={headerGroup.id}>
46+
{headerGroup.headers.map((header) => (
47+
<TableHead key={header.id}>
48+
{header.isPlaceholder
49+
? null
50+
: flexRender(header.column.columnDef.header, header.getContext())}
51+
</TableHead>
52+
))}
53+
</TableRow>
54+
))}
55+
</TableHeader>
56+
<TableBody>
57+
{table.getRowModel().rows.length ? (
58+
table.getRowModel().rows.map((row) => (
59+
<TableRow key={row.id}>
60+
{row.getVisibleCells().map((cell) => (
61+
<TableCell key={cell.id}>
62+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
63+
</TableCell>
64+
))}
65+
</TableRow>
66+
))
67+
) : (
68+
<TableRow>
69+
<TableCell colSpan={contentSlackColumns.length} className="h-24 text-center">
70+
No results.
71+
</TableCell>
72+
</TableRow>
73+
)}
74+
</TableBody>
75+
</Table>
76+
</div>
77+
);
78+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { type ColumnDef } from "@tanstack/react-table";
2+
import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader";
3+
import type { ContentSlackTag } from "@/types/contentSlack";
4+
5+
export const contentSlackColumns: ColumnDef<ContentSlackTag>[] = [
6+
{
7+
id: "user_name",
8+
accessorKey: "user_name",
9+
header: "User",
10+
cell: ({ row }) => {
11+
const tag = row.original;
12+
return (
13+
<div className="flex items-center gap-2">
14+
{tag.user_avatar && (
15+
<img
16+
src={tag.user_avatar}
17+
alt={tag.user_name}
18+
className="h-6 w-6 rounded-full"
19+
/>
20+
)}
21+
<span className="font-medium">{tag.user_name}</span>
22+
</div>
23+
);
24+
},
25+
},
26+
{
27+
id: "timestamp",
28+
accessorKey: "timestamp",
29+
header: ({ column }) => <SortableHeader column={column} label="Timestamp" />,
30+
cell: ({ getValue }) =>
31+
new Date(getValue<string>()).toLocaleString(),
32+
sortingFn: "datetime",
33+
},
34+
{
35+
id: "prompt",
36+
accessorKey: "prompt",
37+
header: "Prompt",
38+
cell: ({ getValue }) => {
39+
const text = getValue<string>();
40+
return (
41+
<span className="line-clamp-2 max-w-xs" title={text}>
42+
{text}
43+
</span>
44+
);
45+
},
46+
},
47+
{
48+
id: "video_links",
49+
accessorFn: (row) => row.video_links.length,
50+
header: ({ column }) => (
51+
<SortableHeader column={column} label="Video Links" />
52+
),
53+
cell: ({ row }) => {
54+
const links = row.original.video_links;
55+
if (links.length === 0) {
56+
return <span className="text-gray-400"></span>;
57+
}
58+
return (
59+
<div className="flex flex-col gap-1">
60+
{links.map((link, i) => (
61+
<a
62+
key={i}
63+
href={link}
64+
target="_blank"
65+
rel="noopener noreferrer"
66+
className="text-sm text-blue-600 hover:underline dark:text-blue-400 truncate max-w-xs"
67+
>
68+
{link}
69+
</a>
70+
))}
71+
</div>
72+
);
73+
},
74+
sortingFn: "basic",
75+
},
76+
];

components/Home/AdminDashboard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default function AdminDashboard() {
1313
<NavButton href="/sandboxes/orgs" label="View Org Commits" />
1414
<NavButton href="/privy" label="View Privy Logins" />
1515
<NavButton href="/coding" label="Coding Agent Tags" />
16+
<NavButton href="/content" label="View Content Agent" />
1617
</nav>
1718
</div>
1819
);

hooks/useContentSlackTags.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import { usePrivy } from "@privy-io/react-auth";
5+
import { fetchContentSlackTags } from "@/lib/recoup/fetchContentSlackTags";
6+
import type { AdminPeriod } from "@/types/admin";
7+
8+
export function useContentSlackTags(period: AdminPeriod) {
9+
const { ready, authenticated, getAccessToken } = usePrivy();
10+
11+
return useQuery({
12+
queryKey: ["admin", "content", "slack", period],
13+
queryFn: async () => {
14+
const token = await getAccessToken();
15+
if (!token) throw new Error("Not authenticated");
16+
return fetchContentSlackTags(token, period);
17+
},
18+
enabled: ready && authenticated,
19+
});
20+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { API_BASE_URL } from "@/lib/consts";
2+
import type { AdminPeriod } from "@/types/admin";
3+
import type { ContentSlackResponse } from "@/types/contentSlack";
4+
5+
export async function fetchContentSlackTags(
6+
accessToken: string,
7+
period: AdminPeriod,
8+
): Promise<ContentSlackResponse> {
9+
const url = new URL(`${API_BASE_URL}/api/admins/content/slack`);
10+
url.searchParams.set("period", period);
11+
12+
const res = await fetch(url.toString(), {
13+
headers: { Authorization: `Bearer ${accessToken}` },
14+
});
15+
16+
if (!res.ok) {
17+
const body = await res.json().catch(() => ({}));
18+
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
19+
}
20+
21+
return res.json();
22+
}

types/contentSlack.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export type ContentSlackTag = {
2+
user_id: string;
3+
user_name: string;
4+
user_avatar: string | null;
5+
prompt: string;
6+
timestamp: string;
7+
channel_id: string;
8+
channel_name: string;
9+
video_links: string[];
10+
};
11+
12+
export type ContentSlackResponse = {
13+
status: "success" | "error";
14+
total: number;
15+
total_videos: number;
16+
tags_with_videos: number;
17+
tags: ContentSlackTag[];
18+
};

0 commit comments

Comments
 (0)