f6975e98a9
Recipe count and storage usage shared the monthly user_usage bucket with AI calls, so both incorrectly reset every month even though nothing was deleted. Only AI calls should be monthly. Recipe count and storage are now derived live from real data (recipes, recipe/review photos, avatar) instead of a counter — deleting a photo or recipe is itself the "decrement", no extra wiring needed. Storage size is tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb) and threaded through presign -> upload -> save. Also fixes avatar removal silently no-oping (client sent a field the PATCH schema didn't recognize). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { notFound } from "next/navigation";
|
|
import { db, users, recipes, userUsage, eq, desc, count, and } from "@epicure/db";
|
|
import { getStorageUsedMb } from "@/lib/tiers";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { UserEditor } from "@/components/admin/user-editor";
|
|
import { ResetUsageButton } from "@/components/admin/reset-usage-button";
|
|
import Link from "next/link";
|
|
import { ArrowLeft } from "lucide-react";
|
|
|
|
export const metadata: Metadata = {};
|
|
|
|
interface PageProps {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
const ROLE_COLORS = {
|
|
user: "secondary",
|
|
moderator: "outline",
|
|
admin: "destructive",
|
|
} as const;
|
|
|
|
const TIER_COLORS = {
|
|
free: "secondary",
|
|
pro: "default",
|
|
family: "default",
|
|
} as const;
|
|
|
|
export default async function AdminUserDetailPage({ params }: PageProps) {
|
|
const { id } = await params;
|
|
|
|
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
|
if (!user) notFound();
|
|
|
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
|
|
const [usage] = await db
|
|
.select()
|
|
.from(userUsage)
|
|
.where(and(eq(userUsage.userId, id), eq(userUsage.month, currentMonth)))
|
|
.limit(1);
|
|
|
|
const [[recipeCountRow], storageUsedMb] = await Promise.all([
|
|
db.select({ count: count() }).from(recipes).where(eq(recipes.authorId, id)),
|
|
getStorageUsedMb(id),
|
|
]);
|
|
|
|
const recentRecipes = await db
|
|
.select({
|
|
id: recipes.id,
|
|
title: recipes.title,
|
|
visibility: recipes.visibility,
|
|
createdAt: recipes.createdAt,
|
|
})
|
|
.from(recipes)
|
|
.where(eq(recipes.authorId, id))
|
|
.orderBy(desc(recipes.createdAt))
|
|
.limit(10);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
href="/admin/users"
|
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back to Users
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<Avatar className="h-16 w-16">
|
|
<AvatarImage src={user.avatarUrl ?? ""} alt={user.name} />
|
|
<AvatarFallback className="text-lg">
|
|
{user.name.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{user.name}</h1>
|
|
<p className="text-muted-foreground">{user.email}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Joined {user.createdAt.toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div className="ml-auto flex gap-2">
|
|
<Badge variant={ROLE_COLORS[user.role]}>{user.role}</Badge>
|
|
<Badge variant={TIER_COLORS[user.tier]}>{user.tier}</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Edit Role & Tier</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<UserEditor userId={user.id} currentRole={user.role} currentTier={user.tier} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Usage & Limits</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">AI Calls Used ({currentMonth})</span>
|
|
<span className="font-medium">{usage?.aiCallsUsed ?? 0}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Total Recipes</span>
|
|
<span className="font-medium">{recipeCountRow?.count ?? 0}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Storage Used</span>
|
|
<span className="font-medium">{storageUsedMb} MB</span>
|
|
</div>
|
|
<div className="pt-2">
|
|
<ResetUsageButton userId={user.id} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div>
|
|
<h2 className="text-lg font-semibold mb-3">Recent Recipes</h2>
|
|
{recentRecipes.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">No recipes yet.</p>
|
|
) : (
|
|
<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">Title</th>
|
|
<th className="px-4 py-3 text-left font-medium">Visibility</th>
|
|
<th className="px-4 py-3 text-left font-medium">Created</th>
|
|
<th className="px-4 py-3 text-left font-medium">Link</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentRecipes.map((recipe) => (
|
|
<tr key={recipe.id} className="border-b last:border-0 hover:bg-muted/30">
|
|
<td className="px-4 py-3 font-medium">{recipe.title}</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant="outline">{recipe.visibility}</Badge>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{recipe.createdAt.toLocaleDateString()}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Link
|
|
href={`/r/${recipe.id}`}
|
|
className="text-primary underline-offset-4 hover:underline text-xs"
|
|
>
|
|
View
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|