Ampelos dashboard: the web face, and the owner of the schema
Split out of the single Ampelos repository. Next.js app, Drizzle schema and migrations, brand art, planning notes. What left: scripts/, which was the agent's job library misfiled under web/ and imported nothing from src/; and deploy/truenas, whose broadcast posts to the scan listener on :3427 -- an agent script -- so it belongs beside the thing it talks to. This repository keeps the schema. The agent speaks raw SQL against the same tables and holds no copy of it, so a rename here can break it silently where it used to be one commit. The README says so, and the agent carries a snapshot to check against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { db } from "@/db/client";
|
||||
import { users, watchingNowItems, watchlistItems } from "@/db/schema";
|
||||
import { count, desc, isNull } from "drizzle-orm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatDate(value: Date) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const [userRows, watchingCounts, watchlistCounts] = await Promise.all([
|
||||
db.select().from(users).orderBy(desc(users.createdAt)),
|
||||
db
|
||||
.select({ userId: watchingNowItems.userId, activeCount: count() })
|
||||
.from(watchingNowItems)
|
||||
.where(isNull(watchingNowItems.removedAt))
|
||||
.groupBy(watchingNowItems.userId),
|
||||
db
|
||||
.select({ userId: watchlistItems.userId, activeCount: count() })
|
||||
.from(watchlistItems)
|
||||
.where(isNull(watchlistItems.removedAt))
|
||||
.groupBy(watchlistItems.userId),
|
||||
]);
|
||||
|
||||
const watchingByUser = new Map(watchingCounts.map((row) => [row.userId, row.activeCount]));
|
||||
const watchlistByUser = new Map(watchlistCounts.map((row) => [row.userId, row.activeCount]));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">People</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-semibold">Users</h1>
|
||||
</div>
|
||||
<section className="admin-panel overflow-hidden">
|
||||
<div className="grid grid-cols-[1.4fr_1fr_0.6fr_0.6fr_0.7fr] gap-4 border-b border-admin-line px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-admin-muted">
|
||||
<span>User</span>
|
||||
<span>Email</span>
|
||||
<span>Watch Now</span>
|
||||
<span>Watchlist</span>
|
||||
<span>Joined</span>
|
||||
</div>
|
||||
<div className="divide-y divide-admin-line">
|
||||
{userRows.length ? userRows.map((user) => (
|
||||
<div key={user.id} className="grid grid-cols-[1.4fr_1fr_0.6fr_0.6fr_0.7fr] gap-4 px-4 py-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">{user.displayName}</p>
|
||||
<p className="text-xs text-admin-muted">{user.isAdmin ? "Admin" : "User"}</p>
|
||||
</div>
|
||||
<p className="truncate text-admin-muted">{user.email}</p>
|
||||
<p>{watchingByUser.get(user.id) ?? 0}/{user.watchingNowTvSlots + user.watchingNowMovieSlots}</p>
|
||||
<p>{watchlistByUser.get(user.id) ?? 0}</p>
|
||||
<p className="text-admin-muted">{formatDate(user.createdAt)}</p>
|
||||
</div>
|
||||
)) : <p className="p-5 text-sm text-admin-muted">No users found.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user