Comprehensive admin + user dashboards (production-ready)

This commit is contained in:
Leon Serfaty
2026-06-07 17:54:30 -04:00
parent 155507f21a
commit f033f00379
122 changed files with 7878 additions and 805 deletions
+319 -1
View File
@@ -1,9 +1,15 @@
"use server";
import { revalidatePath } from "next/cache";
import type { Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
import { bustFlagCache } from "@/lib/flags";
import { stripe } from "@/lib/billing/stripe";
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
import type { GenerationType } from "@/lib/queue/jobs";
type ActionResult = { ok: boolean; error?: string };
async function adminSession() {
const s = await getServerSession();
@@ -50,6 +56,7 @@ export async function toggleFeatureFlagAction(
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.featureFlag.upsert({ where: { key }, create: { key, enabled }, update: { enabled } });
bustFlagCache(); // make the new value take effect immediately on this node
await audit(s.user.id, "flag.toggle", key, { enabled });
revalidatePath("/admin/flags");
return { ok: true };
@@ -66,3 +73,314 @@ export async function reviewContentFlagAction(
revalidatePath("/admin/moderation");
return { ok: true };
}
// ─────────────────────────── Phase 4: billing ops ───────────────────────────
const DAY_MS = 86_400_000;
/** Grant a comped subscription (no payment provider) to a user. */
export async function compPlanAction(
userId: string,
plan: "creator" | "pro" | "agency",
interval: "month" | "year"
): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const now = new Date();
const periodEnd = new Date(now.getTime() + (interval === "year" ? 365 : 30) * DAY_MS);
// One row per (referenceId, provider:"comp"): reuse an existing comp row if present.
const existing = await prisma.subscription.findFirst({
where: { referenceId: userId, provider: "comp" },
});
const data = {
plan,
status: "active",
billingInterval: interval,
periodStart: now,
periodEnd,
cancelAtPeriodEnd: false,
};
if (existing) {
await prisma.subscription.update({ where: { id: existing.id }, data });
} else {
await prisma.subscription.create({
data: { referenceId: userId, provider: "comp", ...data },
});
}
await audit(s.user.id, "subscription.comp", userId, { plan, interval });
revalidatePath(`/admin/users/${userId}`);
revalidatePath("/admin/subscriptions");
return { ok: true };
}
/** Cancel a subscription. Stripe cancels at period end; comp/paypal flip to canceled. */
export async function cancelSubscriptionAdminAction(subId: string): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const sub = await prisma.subscription.findUnique({ where: { id: subId } });
if (!sub) return { ok: false, error: "Subscription not found." };
try {
if (sub.provider === "stripe") {
if (!sub.stripeSubscriptionId) return { ok: false, error: "Missing Stripe subscription id." };
await stripe().subscriptions.update(sub.stripeSubscriptionId, { cancel_at_period_end: true });
await prisma.subscription.update({
where: { id: subId },
data: { cancelAtPeriodEnd: true },
});
} else {
await prisma.subscription.update({
where: { id: subId },
data: { status: "canceled", cancelAtPeriodEnd: true },
});
}
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Cancel failed." };
}
await audit(s.user.id, "subscription.cancel", subId, { provider: sub.provider });
revalidatePath("/admin/subscriptions");
revalidatePath(`/admin/users/${sub.referenceId}`);
return { ok: true };
}
/** Refund the latest charge on a Stripe subscription. PayPal/comp are unsupported here. */
export async function refundLatestAction(subId: string): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const sub = await prisma.subscription.findUnique({ where: { id: subId } });
if (!sub) return { ok: false, error: "Subscription not found." };
if (sub.provider === "paypal") {
return { ok: false, error: "Refund PayPal payments from the PayPal dashboard." };
}
if (sub.provider === "comp") {
return { ok: false, error: "Comped subscriptions have no payment to refund." };
}
if (!sub.stripeSubscriptionId) return { ok: false, error: "Missing Stripe subscription id." };
try {
const stripeSub = await stripe().subscriptions.retrieve(sub.stripeSubscriptionId, {
expand: ["latest_invoice.payment_intent"],
});
const invoice = stripeSub.latest_invoice;
if (!invoice || typeof invoice === "string") {
return { ok: false, error: "No invoice found for this subscription." };
}
// payment_intent is expanded above; normalize to its id (cast via unknown to be
// resilient to Stripe type changes across API versions).
const pi = (invoice as unknown as { payment_intent?: string | { id: string } | null })
.payment_intent;
const paymentIntentId = typeof pi === "string" ? pi : pi?.id;
if (!paymentIntentId) {
return { ok: false, error: "No payment to refund on the latest invoice." };
}
const refund = await stripe().refunds.create({ payment_intent: paymentIntentId });
await audit(s.user.id, "subscription.refund", subId, {
refundId: refund.id,
paymentIntent: paymentIntentId,
amount: refund.amount,
});
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Refund failed." };
}
revalidatePath("/admin/subscriptions");
return { ok: true };
}
// ─────────────────────────── Phase 4: job ops ───────────────────────────
/** Requeue a job: reset the episode to QUEUED and enqueue a fresh generation. */
export async function retryJobAction(jobId: string): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const job = await prisma.generationJob.findUnique({
where: { id: jobId },
include: { episode: { select: { id: true } } },
});
if (!job) return { ok: false, error: "Job not found." };
const type = (job.type as GenerationType) ?? "full";
try {
await prisma.episode.update({
where: { id: job.episodeId },
data: { status: "QUEUED", errorMessage: null },
});
await prisma.generationJob.create({
data: { episodeId: job.episodeId, type: job.type, status: "queued" },
});
await enqueueEpisodeGeneration({ episodeId: job.episodeId, type });
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Retry failed." };
}
await audit(s.user.id, "job.retry", jobId, { episodeId: job.episodeId, type });
revalidatePath("/admin/jobs");
return { ok: true };
}
/** Cancel a job: mark it failed and fail the episode if it was still in progress. */
export async function cancelJobAction(jobId: string): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const job = await prisma.generationJob.findUnique({
where: { id: jobId },
include: { episode: { select: { id: true, status: true } } },
});
if (!job) return { ok: false, error: "Job not found." };
await prisma.generationJob.update({
where: { id: jobId },
data: { status: "failed", error: job.error ?? "Canceled by admin", finishedAt: new Date() },
});
// Only fail the episode if it hasn't already reached a terminal state.
const inProgress = !["READY", "FAILED", "DRAFT"].includes(job.episode.status);
if (inProgress) {
await prisma.episode.update({
where: { id: job.episodeId },
data: { status: "FAILED", errorMessage: "Canceled by admin" },
});
}
await audit(s.user.id, "job.cancel", jobId, { episodeId: job.episodeId });
revalidatePath("/admin/jobs");
return { ok: true };
}
// ─────────────────────────── Phase 5: feature flags ───────────────────────────
/** Update a flag's rollout %, enabled state, and metadata in one shot. */
export async function setRolloutAction(
key: string,
rolloutPct: number,
enabled: boolean,
metadata?: unknown
): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const pct = Math.max(0, Math.min(100, Math.round(rolloutPct)));
// null/undefined clears the column; any JSON value is stored as-is.
const meta: Prisma.InputJsonValue | typeof Prisma.JsonNull =
metadata == null ? Prisma.JsonNull : (metadata as Prisma.InputJsonValue);
await prisma.featureFlag.upsert({
where: { key },
create: { key, enabled, rolloutPct: pct, metadata: meta },
update: { enabled, rolloutPct: pct, metadata: meta },
});
bustFlagCache();
await audit(s.user.id, "flag.rollout", key, { rolloutPct: pct, enabled });
revalidatePath("/admin/flags");
return { ok: true };
}
/** Delete a feature flag row entirely. */
export async function deleteFlagAction(key: string): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.featureFlag.delete({ where: { key } });
bustFlagCache();
await audit(s.user.id, "flag.delete", key);
revalidatePath("/admin/flags");
return { ok: true };
}
// ─────────────────────────── Phase 5: plan tuning ───────────────────────────
export interface PlanUpdateInput {
priceMonthly: number;
priceYearly: number;
limits: {
script: number;
audio: number;
art: number;
repurpose: number;
seats: number;
maxEpisodeMinutes: number;
};
}
/** Override a plan's price/limits in the DB (an override on lib/billing/plans.ts). */
export async function updatePlanAction(key: string, input: PlanUpdateInput): Promise<ActionResult> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const existing = await prisma.plan.findUnique({ where: { key } });
if (!existing) return { ok: false, error: "Plan not found." };
await prisma.plan.update({
where: { key },
data: {
priceMonthly: Math.max(0, Math.round(input.priceMonthly)),
priceYearly: Math.max(0, Math.round(input.priceYearly)),
limits: input.limits as unknown as Prisma.InputJsonValue,
},
});
await audit(s.user.id, "plan.update", key, {
priceMonthly: input.priceMonthly,
priceYearly: input.priceYearly,
});
revalidatePath("/admin/settings");
return { ok: true };
}
// ─────────────────────────── Phase 5: audit export ───────────────────────────
function csvCell(value: unknown): string {
const str = value == null ? "" : typeof value === "string" ? value : JSON.stringify(value);
// Quote always; escape embedded quotes by doubling.
return `"${str.replace(/"/g, '""')}"`;
}
/** Build a CSV of the currently-filtered audit log (up to a sane cap). */
export async function exportAuditCsvAction(filters: {
action?: string;
actor?: string;
}): Promise<{ ok: boolean; csv?: string; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
const where: Prisma.AuditLogWhereInput = {};
if (filters.action) where.action = filters.action;
if (filters.actor) where.actor = { email: { contains: filters.actor, mode: "insensitive" } };
const rows = await prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
take: 5000,
include: { actor: { select: { email: true } } },
});
const header = ["When", "Actor", "Actor type", "Action", "Target", "Details"];
const lines = [header.map(csvCell).join(",")];
for (const r of rows) {
lines.push(
[
r.createdAt.toISOString(),
r.actor?.email ?? "",
r.actorType,
r.action,
r.target ?? "",
r.metadata ?? "",
]
.map(csvCell)
.join(",")
);
}
await audit(s.user.id, "audit.export", undefined, {
rows: rows.length,
action: filters.action ?? null,
});
return { ok: true, csv: lines.join("\r\n") };
}
+62 -74
View File
@@ -1,94 +1,82 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { Wallet, Sparkles, AudioLines } from "lucide-react";
import { getCostBreakdown } from "@/lib/admin/cost";
import { getAiCostSeries } from "@/lib/admin/series";
import { parseRange } from "@/lib/admin/range";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CostChart, type CostPoint } from "@/components/admin/cost-chart";
import { StatCard } from "@/components/admin/ui/stat-card";
import { ChartCard } from "@/components/admin/ui/chart-card";
import { BarSeries } from "@/components/admin/ui/charts";
import { RangePicker } from "@/components/admin/ui/table-controls";
import { DataTable, type Column } from "@/components/admin/ui/data-table";
import { CHART } from "@/components/admin/ui/chart-theme";
export const metadata: Metadata = { title: "Admin · AI usage" };
export default async function AdminAiUsagePage() {
const since = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
const logs = await prisma.aiCostLog.findMany({
where: { createdAt: { gte: since } },
select: { provider: true, operation: true, costUsd: true, createdAt: true },
});
type Outlier = { userId: string; email: string; cost: number };
// Daily totals by provider for the last 14 days.
const byDay = new Map<string, CostPoint>();
for (let i = 13; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
byDay.set(key, { date: key, openai: 0, elevenlabs: 0 });
}
let totalOpenai = 0;
let totalEleven = 0;
const byOperation: Record<string, number> = {};
for (const log of logs) {
const d = log.createdAt;
const key = `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
const point = byDay.get(key);
const cost = Number(log.costUsd);
if (point) {
if (log.provider === "elevenlabs") point.elevenlabs += cost;
else point.openai += cost;
}
if (log.provider === "elevenlabs") totalEleven += cost;
else totalOpenai += cost;
byOperation[log.operation] = (byOperation[log.operation] ?? 0) + cost;
}
const data = Array.from(byDay.values()).map((p) => ({
date: p.date,
openai: Math.round(p.openai * 100) / 100,
elevenlabs: Math.round(p.elevenlabs * 100) / 100,
}));
export default async function AdminAiUsagePage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>;
}) {
const range = parseRange((await searchParams).range);
const [breakdown, series] = await Promise.all([getCostBreakdown(range), getAiCostSeries(range)]);
const usd = (n: number) => `$${n.toFixed(2)}`;
const outlierColumns: Column<Outlier>[] = [
{ key: "email", header: "User", cell: (o) => <span className="font-medium">{o.email}</span> },
{ key: "cost", header: "Spend", align: "right", cell: (o) => <span className="font-semibold">{usd(o.cost)}</span> },
];
return (
<>
<PageHeader title="AI usage & cost" description="Spend across providers over the last 14 days." />
<PageHeader
title="AI usage & cost"
description="Spend across OpenAI and ElevenLabs."
action={<RangePicker value={range} />}
/>
<div className="mb-6 grid gap-4 sm:grid-cols-3">
<Stat label="OpenAI (14d)" value={`$${totalOpenai.toFixed(2)}`} />
<Stat label="ElevenLabs (14d)" value={`$${totalEleven.toFixed(2)}`} />
<Stat label="Total (14d)" value={`$${(totalOpenai + totalEleven).toFixed(2)}`} />
<div className="grid gap-4 sm:grid-cols-3">
<StatCard label="OpenAI" value={usd(breakdown.byProvider.openai)} icon={Sparkles} />
<StatCard label="ElevenLabs" value={usd(breakdown.byProvider.elevenlabs)} icon={AudioLines} />
<StatCard label="Total spend" value={usd(breakdown.total)} icon={Wallet} />
</div>
<Card>
<CardHeader>
<CardTitle>Daily AI spend</CardTitle>
</CardHeader>
<CardContent>
<CostChart data={data} />
</CardContent>
</Card>
<div className="mt-6">
<ChartCard title="Daily AI spend" description="Stacked by provider.">
<BarSeries
data={series}
xKey="date"
format={(v) => `$${v}`}
series={[
{ key: "openai", name: "OpenAI", color: CHART.brand },
{ key: "elevenlabs", name: "ElevenLabs", color: CHART.success },
]}
/>
</ChartCard>
</div>
<Card className="mt-6">
<CardHeader>
<CardTitle>By operation</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{["script", "audio", "art", "repurpose"].map((op) => (
<div key={op} className="rounded-lg border p-3">
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<ChartCard title="By operation" description="Where the spend goes.">
<div className="grid grid-cols-2 gap-3 py-2">
{(["script", "audio", "art", "repurpose"] as const).map((op) => (
<div key={op} className="rounded-xl border border-border p-3">
<p className="text-xs capitalize text-muted-foreground">{op}</p>
<p className="mt-1 text-lg font-semibold">${(byOperation[op] ?? 0).toFixed(2)}</p>
<p className="mt-1 font-display text-lg font-bold">{usd(breakdown.byOperation[op] ?? 0)}</p>
</div>
))}
</div>
</CardContent>
</Card>
</ChartCard>
<ChartCard title="Top spenders" description="Highest AI cost per user this period.">
<DataTable
columns={outlierColumns}
rows={breakdown.outliers}
getRowKey={(o) => o.userId}
empty="No usage in this period."
/>
</ChartCard>
</div>
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
</CardContent>
</Card>
);
}
+54 -35
View File
@@ -1,48 +1,67 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { listAudit, getAuditActions, AUDIT_PAGE_SIZE } from "@/lib/admin/audit";
import { PageHeader } from "@/components/app/page-header";
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { AuditExport } from "@/components/admin/audit-export";
import { AuditMetaViewer } from "@/components/admin/audit-meta-viewer";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = { title: "Admin · Audit log" };
export default async function AdminAuditPage() {
const logs = await prisma.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 100,
include: { actor: { select: { email: true } } },
});
type Row = Awaited<ReturnType<typeof listAudit>>["rows"][number];
export default async function AdminAuditPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const [{ rows, total }, actions] = await Promise.all([
listAudit({ action: sp.action, actor: sp.q, page }),
getAuditActions(),
]);
const columns: Column<Row>[] = [
{
key: "when",
header: "When",
cell: (l) => <span className="whitespace-nowrap text-muted-foreground">{l.createdAt.toLocaleString()}</span>,
},
{ key: "actor", header: "Actor", cell: (l) => l.actor?.email ?? l.actorType },
{ key: "action", header: "Action", cell: (l) => <Badge variant="outline">{l.action}</Badge> },
{
key: "target",
header: "Target",
cell: (l) => <span className="font-mono text-xs text-muted-foreground">{l.target ?? "—"}</span>,
},
{
key: "meta",
header: "Details",
cell: (l) => <AuditMetaViewer metadata={l.metadata} action={l.action} />,
},
];
return (
<>
<PageHeader title="Audit log" description="Recent administrative actions." />
{logs.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">No audit entries yet.</p>
) : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
<tr>
<th className="p-3 font-medium">When</th>
<th className="p-3 font-medium">Actor</th>
<th className="p-3 font-medium">Action</th>
<th className="p-3 font-medium">Target</th>
</tr>
</thead>
<tbody className="divide-y">
{logs.map((l) => (
<tr key={l.id} className="hover:bg-muted/20">
<td className="p-3 text-muted-foreground">{l.createdAt.toLocaleString()}</td>
<td className="p-3">{l.actor?.email ?? l.actorType}</td>
<td className="p-3">
<Badge variant="outline">{l.action}</Badge>
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">{l.target ?? "—"}</td>
</tr>
))}
</tbody>
</table>
<PageHeader title="Audit log" description="Every administrative action, recorded." />
<TableToolbar>
<SearchInput placeholder="Filter by actor email…" />
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
param="action"
placeholder="Action"
allLabel="All actions"
options={actions.map((a) => ({ value: a, label: a }))}
/>
<AuditExport filters={{ action: sp.action, actor: sp.q }} />
</div>
)}
</TableToolbar>
<DataTable columns={columns} rows={rows} getRowKey={(l) => l.id} empty="No audit entries yet." />
<div className="mt-4">
<Pagination page={page} pageSize={AUDIT_PAGE_SIZE} total={total} />
</div>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { AlertTriangle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export default function AdminError({ error, reset }: { error: Error; reset: () => void }) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
<AlertTriangle className="h-6 w-6" />
</span>
<div>
<p className="font-display text-lg font-bold">Something went wrong</p>
<p className="max-w-md text-sm text-muted-foreground">
{error.message || "This admin view failed to load."}
</p>
</div>
<Button onClick={reset}>
<RotateCw className="h-4 w-4" /> Try again
</Button>
</CardContent>
</Card>
);
}
+11 -4
View File
@@ -1,16 +1,23 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { FlagsClient } from "@/components/admin/flags-client";
import { getAdminFlags } from "@/lib/admin/flags";
export const metadata: Metadata = { title: "Admin · Feature flags" };
export default async function AdminFlagsPage() {
const flags = await prisma.featureFlag.findMany({ orderBy: { key: "asc" } });
const flags = await getAdminFlags();
const serialized = flags.map((f) => ({
...f,
updatedAt: f.updatedAt ? f.updatedAt.toISOString() : null,
}));
return (
<>
<PageHeader title="Feature flags" description="Toggle features without a deploy." />
<FlagsClient flags={flags.map((f) => ({ key: f.key, enabled: f.enabled }))} />
<PageHeader
title="Feature flags"
description="Toggle features, tune rollout, and attach metadata — no deploy required."
/>
<FlagsClient flags={serialized} />
</>
);
}
+101 -50
View File
@@ -1,88 +1,139 @@
import type { Metadata } from "next";
import { Activity, CheckCircle2, AlertTriangle } from "lucide-react";
import { Activity, CheckCircle2, AlertTriangle, Cpu, Webhook, Database } from "lucide-react";
import { prisma } from "@/lib/db";
import { getWorkerHealth, getQueueStats } from "@/lib/queue/health";
import { getJobStatusCounts, getEpisodeStatusCounts, listWebhookEvents } from "@/lib/admin/ops";
import { PageHeader } from "@/components/app/page-header";
import { StatCard } from "@/components/admin/ui/stat-card";
import { ChartCard } from "@/components/admin/ui/chart-card";
import { AutoRefresh } from "@/components/admin/ui/auto-refresh";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = { title: "Admin · Health" };
export const metadata: Metadata = { title: "Admin · System health" };
export default async function AdminHealthPage() {
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const [jobGroups, episodeGroups, recentFailures, queuePending] = await Promise.all([
prisma.generationJob.groupBy({ by: ["status"], _count: true }),
prisma.episode.groupBy({ by: ["status"], _count: true }),
prisma.episode.count({ where: { status: "FAILED", updatedAt: { gte: dayAgo } } }),
prisma.generationJob.count({ where: { status: { in: ["queued", "running"] } } }),
const t0 = Date.now();
await prisma.$queryRawUnsafe("SELECT 1");
const dbMs = Date.now() - t0;
const [workers, queues, jobCounts, episodeCounts, webhooks] = await Promise.all([
getWorkerHealth(),
getQueueStats(),
getJobStatusCounts(),
getEpisodeStatusCounts(),
listWebhookEvents({ page: 1, pageSize: 1 }),
]);
const jobCounts = Object.fromEntries(jobGroups.map((g) => [g.status, g._count]));
const queueHealthy = recentFailures < 5;
const anyWorker = workers.length > 0;
const workersAlive = anyWorker && workers.every((w) => w.alive);
const queueDepth = queues.reduce((s, q) => s + q.queued + q.active, 0);
return (
<>
<PageHeader title="System health" description="Generation pipeline and queue status." />
<AutoRefresh seconds={10} />
<PageHeader title="System health" description="Live worker, queue, and delivery status." />
<div className="mb-6 grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Worker"
value={anyWorker ? (workersAlive ? "Alive" : "Stale") : "No heartbeat"}
icon={Cpu}
hint={anyWorker ? `last beat ${workers[0].secondsAgo}s ago` : "worker never started"}
/>
<StatCard label="Queue depth" value={String(queueDepth)} icon={Activity} hint={`${jobCounts.running} running`} />
<StatCard label="Failed jobs" value={String(jobCounts.failed)} icon={AlertTriangle} />
<StatCard
label="Webhook failures (24h)"
value={String(webhooks.recentFailures)}
icon={Webhook}
hint={`${webhooks.recentTotal} delivered`}
/>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Activity className="h-4 w-4" /> Queue
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="h-4 w-4" /> Workers
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between">
<p className="font-display text-3xl font-extrabold tracking-tight">{queuePending}</p>
<Badge variant={queueHealthy ? "success" : "warning"}>
{queueHealthy ? "Healthy" : "Degraded"}
</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Failures (24h)</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2">
{recentFailures === 0 ? (
<CheckCircle2 className="h-5 w-5 text-success" />
<CardContent className="space-y-2">
{workers.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No worker heartbeat yet. Start the worker (<code>npm run worker:dev</code>).
</p>
) : (
<AlertTriangle className="h-5 w-5 text-warning" />
workers.map((w) => (
<div key={w.name} className="flex items-center justify-between rounded-xl border border-border p-3">
<div>
<p className="font-medium">{w.name}</p>
<p className="text-xs text-muted-foreground">last beat {w.secondsAgo}s ago</p>
</div>
<Badge variant={w.alive ? "success" : "destructive"}>{w.alive ? "alive" : "stale"}</Badge>
</div>
))
)}
<p className="font-display text-3xl font-extrabold tracking-tight">{recentFailures}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Running jobs</CardTitle>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-4 w-4" /> Database
</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-3xl font-extrabold tracking-tight">{jobCounts["running"] ?? 0}</p>
<div className="flex items-center justify-between rounded-xl border border-border p-3">
<span className="text-sm text-muted-foreground">Query latency</span>
<Badge variant={dbMs < 200 ? "success" : dbMs < 800 ? "warning" : "destructive"}>{dbMs} ms</Badge>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Generation jobs</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{["queued", "running", "completed", "failed"].map((s) => (
<div key={s} className="flex items-center justify-between text-sm">
<span className="capitalize text-muted-foreground">{s}</span>
<span className="font-medium">{jobCounts[s] ?? 0}</span>
</div>
))}
</CardContent>
</Card>
<div className="mt-6 grid gap-6 lg:grid-cols-3">
<ChartCard className="lg:col-span-2" title="Queues" description="Per-queue job state (from pg-boss).">
{queues.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No queue activity yet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase text-muted-foreground">
<tr>
<th className="py-2 font-medium">Queue</th>
<th className="py-2 text-right font-medium">Queued</th>
<th className="py-2 text-right font-medium">Active</th>
<th className="py-2 text-right font-medium">Retry</th>
<th className="py-2 text-right font-medium">Failed</th>
<th className="py-2 text-right font-medium">Done</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{queues.map((q) => (
<tr key={q.queue}>
<td className="py-2 font-mono text-xs">{q.queue}</td>
<td className="py-2 text-right">{q.queued}</td>
<td className="py-2 text-right">{q.active}</td>
<td className="py-2 text-right">{q.retry}</td>
<td className="py-2 text-right text-destructive">{q.failed}</td>
<td className="py-2 text-right text-muted-foreground">{q.completed}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ChartCard>
<Card>
<CardHeader>
<CardTitle>Episodes by status</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{episodeGroups.map((g) => (
{episodeCounts.map((g) => (
<div key={g.status} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{g.status}</span>
<span className="font-medium">{g._count}</span>
<span className="font-medium">{g.count}</span>
</div>
))}
</CardContent>
+113
View File
@@ -0,0 +1,113 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Clock, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { listJobs, JOBS_PAGE_SIZE, getJobStatusCounts } from "@/lib/admin/ops";
import { PageHeader } from "@/components/app/page-header";
import { StatCard } from "@/components/admin/ui/stat-card";
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { JobRowActions } from "@/components/admin/job-row-actions";
import { Badge, type BadgeProps } from "@/components/ui/badge";
export const metadata: Metadata = { title: "Admin · Jobs" };
type Row = Awaited<ReturnType<typeof listJobs>>["rows"][number];
const STATUS_VARIANT: Record<string, BadgeProps["variant"]> = {
queued: "secondary",
running: "warning",
completed: "success",
failed: "destructive",
};
function duration(start: Date | null, end: Date | null): string {
if (!start || !end) return "—";
return `${Math.max(0, Math.round((end.getTime() - start.getTime()) / 1000))}s`;
}
export default async function AdminJobsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const [{ rows, total }, counts] = await Promise.all([
listJobs({ status: sp.status, page }),
getJobStatusCounts(),
]);
const columns: Column<Row>[] = [
{
key: "episode",
header: "Episode",
cell: (j) => (
<Link href={`/episodes/${j.episode.id}`} className="truncate font-medium hover:text-brand">
{j.episode.title}
</Link>
),
},
{ key: "type", header: "Type", cell: (j) => <span className="capitalize">{j.type}</span> },
{
key: "status",
header: "Status",
cell: (j) => <Badge variant={STATUS_VARIANT[j.status] ?? "secondary"}>{j.status}</Badge>,
},
{ key: "attempts", header: "Attempts", align: "right", cell: (j) => j.attempts },
{
key: "duration",
header: "Duration",
align: "right",
cell: (j) => <span className="text-muted-foreground">{duration(j.startedAt, j.finishedAt)}</span>,
},
{
key: "error",
header: "Error",
cell: (j) =>
j.error ? (
<span className="truncate font-mono text-xs text-destructive" title={j.error}>
{j.error.slice(0, 40)}
</span>
) : (
"—"
),
},
{
key: "actions",
header: "",
align: "right",
cell: (j) => <JobRowActions job={{ id: j.id, status: j.status }} />,
},
];
return (
<>
<PageHeader title="Generation jobs" description="The episode pipeline, job by job." />
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Queued" value={String(counts.queued)} icon={Clock} />
<StatCard label="Running" value={String(counts.running)} icon={Loader2} />
<StatCard label="Completed" value={String(counts.completed)} icon={CheckCircle2} />
<StatCard label="Failed" value={String(counts.failed)} icon={XCircle} />
</div>
<TableToolbar>
<div />
<FilterSelect
param="status"
placeholder="Status"
allLabel="All status"
options={[
{ value: "queued", label: "Queued" },
{ value: "running", label: "Running" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
]}
/>
</TableToolbar>
<DataTable columns={columns} rows={rows} getRowKey={(j) => j.id} empty="No jobs yet." />
<div className="mt-4">
<Pagination page={page} pageSize={JOBS_PAGE_SIZE} total={total} />
</div>
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
export default function AdminLoading() {
return (
<div className="animate-pulse space-y-6">
<div className="space-y-2">
<div className="h-8 w-52 rounded-xl bg-secondary" />
<div className="h-4 w-72 rounded-lg bg-secondary/70" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-28 rounded-2xl border border-border bg-card" />
))}
</div>
<div className="h-80 rounded-2xl border border-border bg-card" />
</div>
);
}
+30 -13
View File
@@ -1,37 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ShieldCheck } from "lucide-react";
import { prisma } from "@/lib/db";
import { getModerationQueue } from "@/lib/admin/ops";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { ModerationActions } from "@/components/admin/moderation-actions";
export const metadata: Metadata = { title: "Admin · Moderation" };
const SEVERITY: Record<string, BadgeProps["variant"]> = {
high: "destructive",
medium: "warning",
low: "secondary",
};
export default async function AdminModerationPage() {
const flags = await prisma.contentFlag.findMany({
where: { status: "open" },
orderBy: { createdAt: "desc" },
include: { episode: { select: { id: true, title: true } } },
});
const flags = await getModerationQueue();
return (
<>
<PageHeader title="Content moderation" description="Review flagged episodes." />
<PageHeader
title="Content moderation"
description="Episodes auto-flagged by AI moderation, awaiting review."
/>
{flags.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<ShieldCheck className="h-10 w-10 text-success" />
<p className="font-medium">Nothing to review</p>
<p className="text-sm text-muted-foreground">There are no open content flags.</p>
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-success/12 text-success">
<ShieldCheck className="h-6 w-6" />
</span>
<div>
<p className="font-display text-lg font-bold">All clear</p>
<p className="text-sm text-muted-foreground">There are no open content flags.</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{flags.map((f) => (
<Card key={f.id}>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div>
<p className="font-medium">{f.episode.title}</p>
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Link href={`/episodes/${f.episode.id}`} className="font-medium hover:text-brand">
{f.episode.title}
</Link>
<Badge variant={SEVERITY[f.severity] ?? "secondary"}>{f.severity}</Badge>
<Badge variant="outline" className="capitalize">{f.source}</Badge>
</div>
<p className="text-sm text-muted-foreground">{f.reason}</p>
</div>
<ModerationActions flagId={f.id} />
+74 -81
View File
@@ -1,96 +1,89 @@
import type { Metadata } from "next";
import { Users, CreditCard, Mic2, DollarSign, TrendingUp, AlertTriangle } from "lucide-react";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { PLANS, PLAN_ORDER, type PlanKey } from "@/lib/billing/plans";
import { DollarSign, TrendingUp, Users, CreditCard, UserPlus, Mic2, Wallet, CheckCircle2 } from "lucide-react";
import { getOverview } from "@/lib/admin/metrics";
import { getSignupSeries, getRevenueSeries } from "@/lib/admin/series";
import { parseRange } from "@/lib/admin/range";
import { PLANS, PLAN_ORDER } from "@/lib/billing/plans";
import { formatPrice } from "@/lib/utils";
import { PageHeader } from "@/components/app/page-header";
import { StatCard } from "@/components/admin/ui/stat-card";
import { ChartCard } from "@/components/admin/ui/chart-card";
import { BarSeries, Donut } from "@/components/admin/ui/charts";
import { RangePicker } from "@/components/admin/ui/table-controls";
import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme";
export const metadata: Metadata = { title: "Admin" };
export const metadata: Metadata = { title: "Admin · Overview" };
export default async function AdminOverviewPage() {
const now = new Date();
const startOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const [userCount, newUsers, activeSubs, episodeCount, failedCount, spend] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
prisma.subscription.findMany({
where: { status: { in: ["active", "trialing"] } },
select: { plan: true },
}),
prisma.episode.count(),
prisma.episode.count({ where: { status: "FAILED" } }),
prisma.aiCostLog.aggregate({ _sum: { costUsd: true }, where: { createdAt: { gte: startOfMonth } } }),
export default async function AdminOverviewPage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>;
}) {
const range = parseRange((await searchParams).range);
const [m, signups, revenue] = await Promise.all([
getOverview(range),
getSignupSeries(range),
getRevenueSeries(range),
]);
const tierCounts: Record<string, number> = {};
let mrr = 0;
for (const sub of activeSubs) {
tierCounts[sub.plan] = (tierCounts[sub.plan] ?? 0) + 1;
mrr += PLANS[(sub.plan as PlanKey) in PLANS ? (sub.plan as PlanKey) : "free"].priceMonthly;
}
const aiSpend = Number(spend._sum.costUsd ?? 0);
const errorRate = episodeCount > 0 ? Math.round((failedCount / episodeCount) * 100) : 0;
const tierData = PLAN_ORDER.map((k) => ({
name: PLANS[k].name,
value: m.tierCounts[k] ?? 0,
color: TIER_COLORS[k],
})).filter((d) => d.value > 0);
const usd = (n: number) => `$${n.toFixed(2)}`;
return (
<>
<PageHeader title="Overview" description="Platform health at a glance." />
<PageHeader
title="Overview"
description="Revenue, growth, and platform health at a glance."
action={<RangePicker value={range} />}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Kpi icon={DollarSign} label="MRR" value={formatPrice(mrr)} hint={`${activeSubs.length} active subs`} />
<Kpi icon={Users} label="Users" value={String(userCount)} hint={`+${newUsers} in 30 days`} />
<Kpi icon={Mic2} label="Episodes generated" value={String(episodeCount)} />
<Kpi icon={TrendingUp} label="AI spend (MTD)" value={`$${aiSpend.toFixed(2)}`} />
<Kpi icon={CreditCard} label="Paying customers" value={String(activeSubs.filter((s) => s.plan !== "free").length)} />
<Kpi icon={AlertTriangle} label="Episode error rate" value={`${errorRate}%`} />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="MRR" value={formatPrice(m.mrr)} icon={DollarSign} hint={`${m.activeSubs} active subs`} />
<StatCard label="ARR" value={formatPrice(m.arr)} icon={TrendingUp} />
<StatCard label="Paying customers" value={String(m.paying)} icon={CreditCard} hint={`ARPU ${formatPrice(m.arpu)}`} />
<StatCard label="Total users" value={String(m.totalUsers)} icon={Users} />
<StatCard label="Signups" value={String(m.signups)} delta={m.signupsDelta} icon={UserPlus} />
<StatCard label="Episodes" value={String(m.episodes)} delta={m.episodesDelta} icon={Mic2} />
<StatCard label="AI spend" value={usd(m.aiSpend)} delta={m.aiSpendDelta} invertDelta icon={Wallet} />
<StatCard label="Success rate" value={`${m.successRate}%`} icon={CheckCircle2} hint={`${m.failedEpisodes} failed · ${m.churned} churned`} />
</div>
<Card className="mt-6">
<CardHeader>
<CardTitle>Subscriptions by tier</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{PLAN_ORDER.map((key) => (
<div key={key} className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium capitalize">{PLANS[key].name}</span>
<Badge variant="secondary">{tierCounts[key] ?? 0}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">{formatPrice(PLANS[key].priceMonthly)}/mo</p>
</div>
))}
</div>
</CardContent>
</Card>
<div className="mt-6 grid gap-6 lg:grid-cols-3">
<ChartCard className="lg:col-span-2" title="Revenue movement" description="New vs churned MRR over the period.">
<BarSeries
data={revenue}
xKey="date"
stacked={false}
format={(v) => `$${v}`}
series={[
{ key: "newMrr", name: "New MRR", color: CHART.brand },
{ key: "churnedMrr", name: "Churned", color: CHART.warning },
]}
/>
</ChartCard>
<ChartCard title="Plan distribution" description="Active subscriptions by tier.">
{tierData.length > 0 ? (
<Donut data={tierData} colors={tierData.map((d) => d.color)} />
) : (
<p className="py-16 text-center text-sm text-muted-foreground">No active subscriptions yet.</p>
)}
</ChartCard>
</div>
<div className="mt-6">
<ChartCard title="New signups" description="Account creations per day.">
<BarSeries
data={signups}
xKey="date"
height={240}
series={[{ key: "signups", name: "Signups", color: CHART.brand }]}
/>
</ChartCard>
</div>
</>
);
}
function Kpi({
icon: Icon,
label,
value,
hint,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
hint?: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</CardContent>
</Card>
);
}
+105
View File
@@ -0,0 +1,105 @@
import type { Metadata } from "next";
import { DollarSign, TrendingUp, Wallet, UserMinus } from "lucide-react";
import { getOverview } from "@/lib/admin/metrics";
import { getRevenueSeries } from "@/lib/admin/series";
import { getRevenueExtras } from "@/lib/admin/billing";
import { parseRange } from "@/lib/admin/range";
import { formatPrice } from "@/lib/utils";
import { PageHeader } from "@/components/app/page-header";
import { StatCard } from "@/components/admin/ui/stat-card";
import { ChartCard } from "@/components/admin/ui/chart-card";
import { BarSeries } from "@/components/admin/ui/charts";
import { RangePicker } from "@/components/admin/ui/table-controls";
import { DataTable, type Column } from "@/components/admin/ui/data-table";
import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = { title: "Admin · Revenue" };
type Churn = { customer: string; plan: string; provider: string; at: Date };
export default async function AdminRevenuePage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>;
}) {
const range = parseRange((await searchParams).range);
const [m, revenue, extras] = await Promise.all([
getOverview(range),
getRevenueSeries(range),
getRevenueExtras(range),
]);
const churnColumns: Column<Churn>[] = [
{ key: "customer", header: "Customer", cell: (c) => <span className="font-medium">{c.customer}</span> },
{ key: "plan", header: "Plan", cell: (c) => <span className="capitalize">{c.plan}</span> },
{ key: "provider", header: "Provider", cell: (c) => <span className="capitalize">{c.provider}</span> },
{ key: "at", header: "Canceled", cell: (c) => <span className="text-muted-foreground">{c.at.toLocaleDateString()}</span> },
];
return (
<>
<PageHeader
title="Revenue"
description="Recurring revenue, growth, and churn."
action={<RangePicker value={range} />}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="MRR" value={formatPrice(m.mrr)} icon={DollarSign} hint={`${m.paying} paying`} />
<StatCard label="ARR" value={formatPrice(m.arr)} icon={TrendingUp} />
<StatCard label="ARPU" value={formatPrice(m.arpu)} icon={Wallet} />
<StatCard label="Churned (period)" value={String(m.churned)} icon={UserMinus} />
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-3">
<ChartCard className="lg:col-span-2" title="New vs churned MRR" description="Movement over the period.">
<BarSeries
data={revenue}
xKey="date"
stacked={false}
format={(v) => `$${v}`}
series={[
{ key: "newMrr", name: "New MRR", color: CHART.brand },
{ key: "churnedMrr", name: "Churned", color: CHART.warning },
]}
/>
</ChartCard>
<ChartCard title="MRR by tier" description="Where recurring revenue comes from.">
<div className="space-y-3 py-2">
{(["creator", "pro", "agency"] as const).map((tier) => {
const cents = extras.tierMrr[tier] ?? 0;
const pct = m.mrr > 0 ? Math.round((cents / m.mrr) * 100) : 0;
return (
<div key={tier} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="capitalize">{tier}</span>
<span className="font-medium">{formatPrice(cents)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full"
style={{ width: `${pct}%`, backgroundColor: TIER_COLORS[tier] }}
/>
</div>
</div>
);
})}
</div>
</ChartCard>
</div>
<div className="mt-6">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-muted-foreground">
Recent churn <Badge variant="secondary">{extras.churnList.length}</Badge>
</h2>
<DataTable
columns={churnColumns}
rows={extras.churnList}
getRowKey={(c, i) => `${c.customer}-${i}`}
empty="No cancellations in this period. 🎉"
/>
</div>
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Info } from "lucide-react";
import { prisma } from "@/lib/db";
import { PLANS, PLAN_ORDER, type PlanKey, type PlanLimits } from "@/lib/billing/plans";
import { PageHeader } from "@/components/app/page-header";
import { PlanEditor, type EditablePlan, type PlanLimitsValue } from "@/components/admin/plan-editor";
export const metadata: Metadata = { title: "Admin · Settings" };
// Only paid tiers are editable here (Free has no price to tune).
const PAID_TIERS = PLAN_ORDER.filter((k) => k !== "free") as Exclude<PlanKey, "free">[];
function coerceLimits(raw: unknown, fallback: PlanLimits): PlanLimitsValue {
const obj = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const num = (k: keyof PlanLimits) =>
typeof obj[k] === "number" ? (obj[k] as number) : fallback[k];
return {
script: num("script"),
audio: num("audio"),
art: num("art"),
repurpose: num("repurpose"),
seats: num("seats"),
maxEpisodeMinutes: num("maxEpisodeMinutes"),
};
}
export default async function AdminSettingsPage() {
const dbPlans = await prisma.plan.findMany();
const byKey = new Map(dbPlans.map((p) => [p.key, p]));
const editable: EditablePlan[] = PAID_TIERS.map((key) => {
const code = PLANS[key];
const db = byKey.get(key);
return {
key,
name: code.name,
priceMonthly: db?.priceMonthly ?? code.priceMonthly,
priceYearly: db?.priceYearly ?? code.priceYearly,
limits: db ? coerceLimits(db.limits, code.limits) : { ...code.limits },
};
});
return (
<>
<PageHeader
title="Settings"
description="Tune plan prices and limits without a deploy."
/>
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-border bg-brand/5 p-4 text-sm">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-brand" />
<p className="text-muted-foreground">
<code className="text-foreground">lib/billing/plans.ts</code> is the code source of
truth for tiers and features. Values saved here are stored in the{" "}
<code className="text-foreground">Plan</code> table and act as a runtime{" "}
<span className="font-semibold text-foreground">override</span> for price and limits.
</p>
</div>
<div className="space-y-6">
{editable.map((plan) => (
<PlanEditor key={plan.key} plan={plan} />
))}
</div>
</>
);
}
+100 -73
View File
@@ -1,91 +1,118 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { PLANS, type PlanKey } from "@/lib/billing/plans";
import { DollarSign, TrendingUp, CreditCard } from "lucide-react";
import { listSubscriptions, SUBS_PAGE_SIZE, type AdminSubRow } from "@/lib/admin/billing";
import { getOverview } from "@/lib/admin/metrics";
import { formatPrice } from "@/lib/utils";
import { PageHeader } from "@/components/app/page-header";
import { StatCard } from "@/components/admin/ui/stat-card";
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { SubscriptionRowActions } from "@/components/admin/subscription-row-actions";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = { title: "Admin · Subscriptions" };
export default async function AdminSubscriptionsPage() {
const subs = await prisma.subscription.findMany({ orderBy: { createdAt: "desc" }, take: 200 });
const refIds = subs.map((s) => s.referenceId);
const [users, orgs] = await Promise.all([
prisma.user.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true, email: true } }),
prisma.organization.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true } }),
export default async function AdminSubscriptionsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const [m, { rows, total }] = await Promise.all([
getOverview("30d"),
listSubscriptions({ status: sp.status, provider: sp.provider, search: sp.q, page }),
]);
const nameByRef = new Map<string, string>();
for (const u of users) nameByRef.set(u.id, u.email);
for (const o of orgs) nameByRef.set(o.id, o.name);
const active = subs.filter((s) => ["active", "trialing"].includes(s.status));
const mrr = active.reduce((sum, s) => sum + (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0), 0);
const stripeCount = active.filter((s) => s.provider === "stripe").length;
const paypalCount = active.filter((s) => s.provider === "paypal").length;
const columns: Column<AdminSubRow>[] = [
{
key: "customer",
header: "Customer",
cell: (s) => <span className="truncate font-medium">{s.customer}</span>,
},
{ key: "plan", header: "Plan", cell: (s) => <span className="capitalize">{s.plan}</span> },
{ key: "provider", header: "Provider", cell: (s) => <span className="capitalize">{s.provider}</span> },
{
key: "status",
header: "Status",
cell: (s) => (
<Badge variant={["active", "trialing"].includes(s.status) ? "success" : "secondary"}>
{s.cancelAtPeriodEnd ? "cancels soon" : s.status}
</Badge>
),
},
{
key: "renews",
header: "Renews",
cell: (s) => (
<span className="text-muted-foreground">{s.periodEnd ? s.periodEnd.toLocaleDateString() : "—"}</span>
),
},
{
key: "actions",
header: "",
align: "right",
cell: (s) => (
<SubscriptionRowActions
sub={{
id: s.id,
referenceId: s.referenceId,
provider: s.provider,
status: s.status,
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
}}
/>
),
},
];
return (
<>
<PageHeader title="Subscriptions" description="Revenue and active subscriptions." />
<div className="mb-6 grid gap-4 sm:grid-cols-4">
<Stat label="MRR" value={formatPrice(mrr)} />
<Stat label="ARR" value={formatPrice(mrr * 12)} />
<Stat label="Stripe" value={String(stripeCount)} />
<Stat label="PayPal" value={String(paypalCount)} />
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="MRR" value={formatPrice(m.mrr)} icon={DollarSign} />
<StatCard label="ARR" value={formatPrice(m.arr)} icon={TrendingUp} />
<StatCard label="Stripe" value={String(m.providerSplit.stripe)} icon={CreditCard} />
<StatCard
label="PayPal"
value={String(m.providerSplit.paypal)}
icon={CreditCard}
hint={m.providerSplit.comp ? `${m.providerSplit.comp} comped` : undefined}
/>
</div>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
<tr>
<th className="p-3 font-medium">Customer</th>
<th className="p-3 font-medium">Plan</th>
<th className="p-3 font-medium">Provider</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Renews</th>
</tr>
</thead>
<tbody className="divide-y">
{subs.map((s) => (
<tr key={s.id} className="hover:bg-muted/20">
<td className="p-3">{nameByRef.get(s.referenceId) ?? s.referenceId}</td>
<td className="p-3 capitalize">{s.plan}</td>
<td className="p-3 capitalize">{s.provider}</td>
<td className="p-3">
<Badge variant={["active", "trialing"].includes(s.status) ? "success" : "secondary"}>
{s.cancelAtPeriodEnd ? "cancels soon" : s.status}
</Badge>
</td>
<td className="p-3 text-muted-foreground">
{s.periodEnd ? s.periodEnd.toLocaleDateString() : "—"}
</td>
</tr>
))}
{subs.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-muted-foreground">
No subscriptions yet.
</td>
</tr>
)}
</tbody>
</table>
<TableToolbar>
<SearchInput placeholder="Search customer…" />
<div className="flex flex-wrap gap-2">
<FilterSelect
param="status"
placeholder="Status"
allLabel="All status"
options={[
{ value: "active", label: "Active" },
{ value: "trialing", label: "Trialing" },
{ value: "past_due", label: "Past due" },
{ value: "canceled", label: "Canceled" },
{ value: "paused", label: "Paused" },
]}
/>
<FilterSelect
param="provider"
placeholder="Provider"
allLabel="All providers"
options={[
{ value: "stripe", label: "Stripe" },
{ value: "paypal", label: "PayPal" },
{ value: "comp", label: "Comped" },
]}
/>
</div>
</TableToolbar>
<DataTable columns={columns} rows={rows} getRowKey={(s) => s.id} empty="No subscriptions match your filters." />
<div className="mt-4">
<Pagination page={page} pageSize={SUBS_PAGE_SIZE} total={total} />
</div>
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
</CardContent>
</Card>
);
}
+219
View File
@@ -0,0 +1,219 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CreditCard, DollarSign, Mic, Activity } from "lucide-react";
import { getUserDetail } from "@/lib/admin/users";
import { UNLIMITED, type UsageMetric } from "@/lib/billing/plans";
import { formatPrice } from "@/lib/utils";
import { PageHeader } from "@/components/app/page-header";
import { StatCard } from "@/components/admin/ui/stat-card";
import { DataTable, type Column } from "@/components/admin/ui/data-table";
import { UserDetailActions } from "@/components/admin/user-detail-actions";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
export const metadata: Metadata = { title: "Admin · User detail" };
const USAGE_METRICS: { metric: UsageMetric; label: string }[] = [
{ metric: "script", label: "Scripts" },
{ metric: "audio", label: "Audio" },
{ metric: "art", label: "Cover art" },
{ metric: "repurpose", label: "Repurpose" },
];
function initials(name: string): string {
return (
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join("") || "?"
);
}
type UserDetail = NonNullable<Awaited<ReturnType<typeof getUserDetail>>>;
type AuditRow = UserDetail["audit"][number];
export default async function AdminUserDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const detail = await getUserDetail(id);
if (!detail) notFound();
const { user, subscription, plan, usage, episodes, episodeCount, lifetimeCost, audit } = detail;
const usageSummary = USAGE_METRICS.map(({ metric, label }) => {
const used = usage[metric] ?? 0;
const limit = plan.limits[metric];
const unlimited = limit === UNLIMITED;
const pct = unlimited ? 0 : limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
return { metric, label, used, limit, unlimited, pct };
});
const auditColumns: Column<AuditRow>[] = [
{
key: "when",
header: "When",
cell: (l) => (
<span className="whitespace-nowrap text-muted-foreground">
{l.createdAt.toLocaleString()}
</span>
),
},
{ key: "actor", header: "Actor", cell: (l) => l.actor?.email ?? l.actorType },
{ key: "action", header: "Action", cell: (l) => <Badge variant="outline">{l.action}</Badge> },
{
key: "meta",
header: "Details",
cell: (l) =>
l.metadata ? (
<span className="font-mono text-xs text-muted-foreground">
{JSON.stringify(l.metadata).slice(0, 80)}
</span>
) : (
"—"
),
},
];
return (
<>
<PageHeader
title="User detail"
description="Account, usage, and history for this customer."
/>
{/* Identity header */}
<Card className="mb-6">
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Avatar className="h-14 w-14">
{user.image && <AvatarImage src={user.image} alt={user.name} />}
<AvatarFallback className="text-base">{initials(user.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-display text-xl font-extrabold tracking-tight">{user.name}</p>
{user.role === "admin" && <Badge>admin</Badge>}
{user.banned ? (
<Badge variant="destructive">banned</Badge>
) : (
<Badge variant="success">active</Badge>
)}
</div>
<p className="truncate text-sm text-muted-foreground">{user.email}</p>
<p className="text-xs text-muted-foreground/70">
Joined {user.createdAt.toLocaleDateString()} · <span className="font-mono">{user.id}</span>
</p>
</div>
</div>
<UserDetailActions
user={{ id: user.id, role: user.role ?? "user", banned: !!user.banned }}
/>
</CardContent>
</Card>
{/* Key stats */}
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Plan"
value={plan.name}
icon={CreditCard}
hint={
subscription
? `${subscription.provider} · ${subscription.status}`
: "No active subscription"
}
/>
<StatCard
label="Lifetime AI cost"
value={`$${lifetimeCost.toFixed(2)}`}
icon={DollarSign}
hint="Across all generations"
/>
<StatCard label="Episodes" value={String(episodeCount)} icon={Mic} hint="Total created" />
<StatCard
label="Subscription"
value={subscription ? formatPrice(plan.priceMonthly) : "Free"}
icon={Activity}
hint={subscription?.periodEnd ? `Renews ${subscription.periodEnd.toLocaleDateString()}` : undefined}
/>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Usage vs plan limits */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Usage this period</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{usageSummary.map((u) => (
<div key={u.metric} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{u.label}</span>
<span className="text-muted-foreground">
{u.used} / {u.unlimited ? "∞" : u.limit}
</span>
</div>
<Progress
value={u.pct}
indicatorClassName={
!u.unlimited && u.pct >= 90 ? "bg-destructive" : u.pct >= 75 ? "bg-warning" : undefined
}
/>
</div>
))}
</CardContent>
</Card>
{/* Recent episodes */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Recent episodes</CardTitle>
</CardHeader>
<CardContent>
{episodes.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No episodes yet.</p>
) : (
<ul className="divide-y divide-border">
{episodes.map((ep) => (
<li key={ep.id} className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<Link
href={`/episodes/${ep.id}`}
className="truncate font-medium hover:text-brand"
>
{ep.title}
</Link>
<p className="text-xs text-muted-foreground">
{ep.format} · {ep.createdAt.toLocaleDateString()}
</p>
</div>
<Badge variant="outline">{ep.status}</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
{/* Audit history */}
<div className="mt-6 space-y-3">
<h2 className="font-display text-lg font-bold tracking-tight">Audit history</h2>
<DataTable
columns={auditColumns}
rows={audit}
getRowKey={(l) => l.id}
empty="No audit entries for this user."
/>
</div>
</>
);
}
+93 -23
View File
@@ -1,34 +1,104 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { listUsers, USERS_PAGE_SIZE, type AdminUserRow } from "@/lib/admin/users";
import { PageHeader } from "@/components/app/page-header";
import { UsersTable } from "@/components/admin/users-table";
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { UserRowActions } from "@/components/admin/user-row-actions";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = { title: "Admin · Users" };
export default async function AdminUsersPage() {
const [users, subs] = await Promise.all([
prisma.user.findMany({ orderBy: { createdAt: "desc" }, take: 200 }),
prisma.subscription.findMany({
where: { status: { in: ["active", "trialing"] } },
select: { referenceId: true, plan: true },
}),
]);
const planByRef = new Map(subs.map((s) => [s.referenceId, s.plan]));
export default async function AdminUsersPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const { rows, total } = await listUsers({
search: sp.q,
plan: sp.plan,
role: sp.role,
status: sp.status,
sort: sp.sort,
page,
});
const columns: Column<AdminUserRow>[] = [
{
key: "user",
header: "User",
sortKey: "name",
cell: (u) => (
<div className="min-w-0">
<p className="truncate font-medium">{u.name}</p>
<p className="truncate text-xs text-muted-foreground">{u.email}</p>
</div>
),
},
{ key: "plan", header: "Plan", cell: (u) => <span className="capitalize">{u.plan}</span> },
{
key: "role",
header: "Role",
cell: (u) =>
u.role === "admin" ? <Badge>admin</Badge> : <span className="text-muted-foreground">user</span>,
},
{
key: "status",
header: "Status",
cell: (u) =>
u.banned ? <Badge variant="destructive">banned</Badge> : <Badge variant="success">active</Badge>,
},
{
key: "createdAt",
header: "Joined",
sortKey: "createdAt",
cell: (u) => <span className="text-muted-foreground">{u.createdAt.toLocaleDateString()}</span>,
},
{ key: "actions", header: "", align: "right", cell: (u) => <UserRowActions user={u} /> },
];
return (
<>
<PageHeader title="Users" description={`${users.length} most recent users.`} />
<UsersTable
users={users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role ?? "user",
banned: !!u.banned,
plan: planByRef.get(u.id) ?? "free",
createdAt: u.createdAt.toISOString(),
}))}
/>
<PageHeader title="Users" description={`${total} total user${total === 1 ? "" : "s"}.`} />
<TableToolbar>
<SearchInput placeholder="Search name or email…" />
<div className="flex flex-wrap gap-2">
<FilterSelect
param="plan"
placeholder="Plan"
allLabel="All plans"
options={[
{ value: "free", label: "Free" },
{ value: "creator", label: "Creator" },
{ value: "pro", label: "Pro" },
{ value: "agency", label: "Agency" },
]}
/>
<FilterSelect
param="role"
placeholder="Role"
allLabel="All roles"
options={[
{ value: "admin", label: "Admin" },
{ value: "user", label: "User" },
]}
/>
<FilterSelect
param="status"
placeholder="Status"
allLabel="All status"
options={[
{ value: "active", label: "Active" },
{ value: "banned", label: "Banned" },
]}
/>
</div>
</TableToolbar>
<DataTable columns={columns} rows={rows} getRowKey={(u) => u.id} empty="No users match your filters." />
<div className="mt-4">
<Pagination page={page} pageSize={USERS_PAGE_SIZE} total={total} />
</div>
</>
);
}
+95
View File
@@ -0,0 +1,95 @@
import type { Metadata } from "next";
import { Webhook, CheckCircle2, AlertTriangle } from "lucide-react";
import { listWebhookEvents, WEBHOOKS_PAGE_SIZE } from "@/lib/admin/ops";
import { PageHeader } from "@/components/app/page-header";
import { StatCard } from "@/components/admin/ui/stat-card";
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
import { Badge, type BadgeProps } from "@/components/ui/badge";
export const metadata: Metadata = { title: "Admin · Webhooks" };
type Row = Awaited<ReturnType<typeof listWebhookEvents>>["rows"][number];
const VARIANT: Record<string, BadgeProps["variant"]> = {
processed: "success",
failed: "destructive",
skipped: "secondary",
};
export default async function AdminWebhooksPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1"));
const { rows, total, recentFailures, recentTotal } = await listWebhookEvents({
provider: sp.provider,
status: sp.status,
page,
});
const columns: Column<Row>[] = [
{ key: "provider", header: "Provider", cell: (w) => <span className="capitalize">{w.provider}</span> },
{ key: "type", header: "Event", cell: (w) => <span className="font-mono text-xs">{w.type}</span> },
{ key: "status", header: "Status", cell: (w) => <Badge variant={VARIANT[w.status] ?? "secondary"}>{w.status}</Badge> },
{
key: "error",
header: "Error",
cell: (w) =>
w.error ? (
<span className="truncate font-mono text-xs text-destructive" title={w.error}>
{w.error.slice(0, 40)}
</span>
) : (
"—"
),
},
{
key: "when",
header: "When",
cell: (w) => <span className="whitespace-nowrap text-muted-foreground">{w.createdAt.toLocaleString()}</span>,
},
];
return (
<>
<PageHeader title="Webhook deliveries" description="Stripe & PayPal events received." />
<div className="mb-6 grid gap-4 sm:grid-cols-3">
<StatCard label="Delivered (24h)" value={String(recentTotal)} icon={Webhook} />
<StatCard label="Succeeded (24h)" value={String(recentTotal - recentFailures)} icon={CheckCircle2} />
<StatCard label="Failed (24h)" value={String(recentFailures)} icon={AlertTriangle} />
</div>
<TableToolbar>
<div />
<div className="flex gap-2">
<FilterSelect
param="provider"
placeholder="Provider"
allLabel="All providers"
options={[
{ value: "stripe", label: "Stripe" },
{ value: "paypal", label: "PayPal" },
]}
/>
<FilterSelect
param="status"
placeholder="Status"
allLabel="All status"
options={[
{ value: "processed", label: "Processed" },
{ value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
]}
/>
</div>
</TableToolbar>
<DataTable columns={columns} rows={rows} getRowKey={(w) => w.id} empty="No webhook deliveries recorded yet." />
<div className="mt-4">
<Pagination page={page} pageSize={WEBHOOKS_PAGE_SIZE} total={total} />
</div>
</>
);
}
+10 -6
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ShieldCheck, ArrowLeft } from "lucide-react";
import { requireAdmin } from "@/lib/auth/guards";
import { AdminSidebar } from "@/components/admin/admin-sidebar";
import { AdminMobileNav } from "@/components/admin/admin-mobile-nav";
import { UserMenu } from "@/components/app/user-menu";
import { Button } from "@/components/ui/button";
@@ -14,12 +15,15 @@ export default async function AdminLayout({ children }: { children: React.ReactN
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/85 px-4 backdrop-blur-md md:px-6">
<Link href="/admin" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-foreground text-background">
<ShieldCheck className="h-5 w-5" />
</span>
<span>PodcastYes Admin</span>
</Link>
<div className="flex items-center gap-1">
<AdminMobileNav />
<Link href="/admin" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-foreground text-background">
<ShieldCheck className="h-5 w-5" />
</span>
<span>PodcastYes Admin</span>
</Link>
</div>
<div className="flex items-center gap-3">
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard">