cba5d9c3ac
Sticky sidebar with back-to-app link. Overview: user counts, recipe photos, AI calls/month. User management with role/tier editing. Audit log (all admin actions). Storage page with photo breakdown by tier. AI config showing DB vs .env key source. Site settings page to override .env values at runtime (encrypted in DB).
52 lines
2.2 KiB
TypeScript
52 lines
2.2 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { db } from "@epicure/db";
|
|
import { users, recipes, userUsage, recipePhotos } from "@epicure/db";
|
|
import { count, eq, sql } from "@epicure/db";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Users, BookOpen, Sparkles, Image } from "lucide-react";
|
|
|
|
export const metadata: Metadata = { title: "Admin Overview" };
|
|
|
|
export default async function AdminPage() {
|
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
|
|
const [userCount, recipeCount, proUserCount, aiUsage, imageCount] = await Promise.all([
|
|
db.select({ count: count() }).from(users).then((r) => r[0]),
|
|
db.select({ count: count() }).from(recipes).then((r) => r[0]),
|
|
db.select({ count: count() }).from(users).where(eq(users.tier, "pro")).then((r) => r[0]),
|
|
db
|
|
.select({ total: sql<string>`coalesce(sum(${userUsage.aiCallsUsed}), 0)` })
|
|
.from(userUsage)
|
|
.where(eq(userUsage.month, currentMonth))
|
|
.then((r) => r[0]),
|
|
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
|
|
]);
|
|
|
|
const stats = [
|
|
{ label: "Total Users", value: userCount?.count ?? 0, sub: `${proUserCount?.count ?? 0} pro`, icon: Users },
|
|
{ label: "Total Recipes", value: recipeCount?.count ?? 0, icon: BookOpen },
|
|
{ label: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles },
|
|
{ label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-2xl font-bold tracking-tight">Overview</h1>
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{stats.map(({ label, value, sub, icon: Icon }) => (
|
|
<Card key={label}>
|
|
<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>
|
|
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
|
|
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|