c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
143 lines
5.6 KiB
TypeScript
143 lines
5.6 KiB
TypeScript
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 = {};
|
|
|
|
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);
|
|
const familyPhotos = Number(photosByTier.find((r) => r.tier === "family")?.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-4 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>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Family Tier Photos</CardTitle>
|
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{familyPhotos.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>
|
|
);
|
|
}
|