Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
@@ -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") };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user