Initial commit: PodcastYes — AI podcast platform
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
async function adminSession() {
|
||||
const s = await getServerSession();
|
||||
if (!s || s.user.role !== "admin") return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
async function audit(
|
||||
actorId: string,
|
||||
action: string,
|
||||
target?: string,
|
||||
metadata?: Prisma.InputJsonValue
|
||||
) {
|
||||
await prisma.auditLog.create({
|
||||
data: { actorId, actorType: "admin", action, target, metadata },
|
||||
});
|
||||
}
|
||||
|
||||
export async function banUserAction(userId: string, ban: boolean): Promise<{ ok: boolean; error?: string }> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
if (userId === s.user.id) return { ok: false, error: "You can't ban yourself." };
|
||||
await prisma.user.update({ where: { id: userId }, data: { banned: ban } });
|
||||
// Revoke sessions on ban so access stops immediately.
|
||||
if (ban) await prisma.session.deleteMany({ where: { userId } });
|
||||
await audit(s.user.id, ban ? "user.ban" : "user.unban", userId);
|
||||
revalidatePath("/admin/users");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function setRoleAction(userId: string, role: "admin" | "user"): Promise<{ ok: boolean; error?: string }> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
await prisma.user.update({ where: { id: userId }, data: { role } });
|
||||
await audit(s.user.id, "user.role", userId, { role });
|
||||
revalidatePath("/admin/users");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function toggleFeatureFlagAction(
|
||||
key: string,
|
||||
enabled: boolean
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
await prisma.featureFlag.upsert({ where: { key }, create: { key, enabled }, update: { enabled } });
|
||||
await audit(s.user.id, "flag.toggle", key, { enabled });
|
||||
revalidatePath("/admin/flags");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function reviewContentFlagAction(
|
||||
flagId: string,
|
||||
status: "reviewed" | "removed"
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
await prisma.contentFlag.update({ where: { id: flagId }, data: { status, reviewedBy: s.user.id } });
|
||||
await audit(s.user.id, "content.review", flagId, { status });
|
||||
revalidatePath("/admin/moderation");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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 { CostChart, type CostPoint } from "@/components/admin/cost-chart";
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
// 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,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="AI usage & cost" description="Spend across providers over the last 14 days." />
|
||||
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily AI spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CostChart data={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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">
|
||||
<p className="text-xs capitalize text-muted-foreground">{op}</p>
|
||||
<p className="mt-1 text-lg font-semibold">${(byOperation[op] ?? 0).toFixed(2)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
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 } } },
|
||||
});
|
||||
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { FlagsClient } from "@/components/admin/flags-client";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Feature flags" };
|
||||
|
||||
export default async function AdminFlagsPage() {
|
||||
const flags = await prisma.featureFlag.findMany({ orderBy: { key: "asc" } });
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Feature flags" description="Toggle features without a deploy." />
|
||||
<FlagsClient flags={flags.map((f) => ({ key: f.key, enabled: f.enabled }))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Activity, CheckCircle2, 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";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · 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 jobCounts = Object.fromEntries(jobGroups.map((g) => [g.status, g._count]));
|
||||
const queueHealthy = recentFailures < 5;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="System health" description="Generation pipeline and queue status." />
|
||||
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-3">
|
||||
<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
|
||||
</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" />
|
||||
) : (
|
||||
<AlertTriangle className="h-5 w-5 text-warning" />
|
||||
)}
|
||||
<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>
|
||||
<CardContent>
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{jobCounts["running"] ?? 0}</p>
|
||||
</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>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Episodes by status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{episodeGroups.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>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ModerationActions } from "@/components/admin/moderation-actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Moderation" };
|
||||
|
||||
export default async function AdminModerationPage() {
|
||||
const flags = await prisma.contentFlag.findMany({
|
||||
where: { status: "open" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { episode: { select: { id: true, title: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Content moderation" description="Review flagged episodes." />
|
||||
{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>
|
||||
</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>
|
||||
<p className="text-sm text-muted-foreground">{f.reason}</p>
|
||||
</div>
|
||||
<ModerationActions flagId={f.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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 { formatPrice } from "@/lib/utils";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin" };
|
||||
|
||||
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 } } }),
|
||||
]);
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Overview" description="Platform health at a glance." />
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,91 @@
|
||||
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 { formatPrice } from "@/lib/utils";
|
||||
|
||||
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 } }),
|
||||
]);
|
||||
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;
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
</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,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { UsersTable } from "@/components/admin/users-table";
|
||||
|
||||
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]));
|
||||
|
||||
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(),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 { UserMenu } from "@/components/app/user-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Authed, DB-backed admin surface — never statically prerender.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireAdmin();
|
||||
|
||||
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-3">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/dashboard">
|
||||
<ArrowLeft className="h-4 w-4" /> Back to app
|
||||
</Link>
|
||||
</Button>
|
||||
<UserMenu name={session.user.name} email={session.user.email} image={session.user.image} isAdmin />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1">
|
||||
<aside className="hidden w-64 shrink-0 border-r border-border bg-background md:block">
|
||||
<div className="sticky top-16">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 bg-secondary/50">
|
||||
<div className="container max-w-6xl py-8 md:py-10">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user