feat(admin): admin panel with overview, users, audit logs, storage, AI config, settings
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).
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import type { Metadata } from "next";
|
||||
import { db, users, recipePhotos, recipes, eq, desc, sql, count } from "@epicure/db";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HardDrive } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = { title: "Storage Usage" };
|
||||
|
||||
export default async function AdminStoragePage() {
|
||||
const [totalPhotos, recipesWithPhotos, photosByTier, topUsers] = await Promise.all([
|
||||
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(recipePhotos)
|
||||
.where(eq(recipePhotos.isCover, true))
|
||||
.then((r) => r[0]),
|
||||
db
|
||||
.select({
|
||||
tier: users.tier,
|
||||
photoCount: sql<string>`count(${recipePhotos.id})`,
|
||||
})
|
||||
.from(recipePhotos)
|
||||
.innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id))
|
||||
.innerJoin(users, eq(recipes.authorId, users.id))
|
||||
.groupBy(users.tier),
|
||||
db
|
||||
.select({
|
||||
userId: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
tier: users.tier,
|
||||
photoCount: sql<string>`count(${recipePhotos.id})`,
|
||||
})
|
||||
.from(recipePhotos)
|
||||
.innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id))
|
||||
.innerJoin(users, eq(recipes.authorId, users.id))
|
||||
.groupBy(users.id, users.name, users.email, users.tier)
|
||||
.orderBy(desc(sql`count(${recipePhotos.id})`))
|
||||
.limit(10),
|
||||
]);
|
||||
|
||||
const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.photoCount ?? 0);
|
||||
const proPhotos = Number(photosByTier.find((r) => r.tier === "pro")?.photoCount ?? 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Storage Usage</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Photo storage breakdown by tier and user.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Photos</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{(totalPhotos?.count ?? 0).toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{recipesWithPhotos?.count ?? 0} cover photos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Free Tier Photos</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{freePhotos.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Pro Tier Photos</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{proPhotos.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Top 10 Users by Photo Count</h2>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">User</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Tier</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Photos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No photos uploaded yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
topUsers.map((row) => (
|
||||
<tr key={row.userId} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{row.name ?? "Unknown"}</div>
|
||||
{row.email && (
|
||||
<div className="text-xs text-muted-foreground">{row.email}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={row.tier === "pro" ? "default" : "secondary"}>
|
||||
{row.tier ?? "—"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">
|
||||
{Number(row.photoCount).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user