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,281 @@
|
||||
import { auth, signOut } from "@/auth";
|
||||
import { db } from "@/db/client";
|
||||
import { adminOverrides, externalIds, mediaItems, users, watchingNowItems } from "@/db/schema";
|
||||
import { getCatalogItems, type CatalogKind } from "@/lib/catalog";
|
||||
import { and, eq, gt, inArray, isNull, or } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CatalogBoard } from "./catalog-board";
|
||||
import { CatalogControls } from "./catalog-controls";
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
type WatchNowItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
mediaType: "tv_series" | "movie";
|
||||
year: number | null;
|
||||
posterPath: string | null;
|
||||
slotNumber: number | null;
|
||||
};
|
||||
|
||||
const tabs: Array<{ key: CatalogKind; label: string }> = [
|
||||
{ key: "television", label: "Television" },
|
||||
{ key: "movies", label: "Movies" },
|
||||
{ key: "music", label: "Music" },
|
||||
];
|
||||
|
||||
function singleParam(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function activeKind(value: string | string[] | undefined): CatalogKind {
|
||||
const tab = singleParam(value);
|
||||
|
||||
if (tab === "movies" || tab === "music") {
|
||||
return tab;
|
||||
}
|
||||
|
||||
return "television";
|
||||
}
|
||||
|
||||
function mediaTypeForKind(kind: CatalogKind) {
|
||||
return kind === "movies" ? "movie" : "tv_series";
|
||||
}
|
||||
|
||||
function hrefFor(next: Record<string, string | null>) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return query ? "/?" + query : "/";
|
||||
}
|
||||
|
||||
async function getCollectionIds(kind: CatalogKind, ids: string[]) {
|
||||
if (kind === "music" || ids.length === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ externalId: externalIds.externalId })
|
||||
.from(externalIds)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, externalIds.mediaItemId))
|
||||
.where(
|
||||
and(
|
||||
eq(externalIds.source, "tmdb"),
|
||||
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
||||
inArray(externalIds.externalId, ids),
|
||||
),
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.externalId));
|
||||
}
|
||||
|
||||
async function getSlotCount(userId: string, kind: CatalogKind) {
|
||||
if (kind === "music") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({ tv: users.watchingNowTvSlots, movies: users.watchingNowMovieSlots })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
return kind === "movies" ? row?.movies ?? 10 : row?.tv ?? 5;
|
||||
}
|
||||
|
||||
async function getWatchNow(userId: string, kind: CatalogKind): Promise<WatchNowItem[]> {
|
||||
if (kind === "music") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: watchingNowItems.id,
|
||||
title: mediaItems.title,
|
||||
mediaType: mediaItems.mediaType,
|
||||
year: mediaItems.year,
|
||||
posterPath: mediaItems.posterPath,
|
||||
slotNumber: watchingNowItems.slotNumber,
|
||||
})
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.userId, userId),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
||||
),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Purged titles, for the admin buttons. An expired override is not in force, so
|
||||
// it must not show as purged -- the classifier applies the same test.
|
||||
async function getPurgedExternalIds(kind: CatalogKind) {
|
||||
if (kind === "music") {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ externalId: externalIds.externalId })
|
||||
.from(adminOverrides)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, adminOverrides.mediaItemId))
|
||||
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
|
||||
.where(
|
||||
and(
|
||||
eq(adminOverrides.overrideType, "purge"),
|
||||
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
||||
eq(externalIds.source, "tmdb"),
|
||||
or(isNull(adminOverrides.expiresAt), gt(adminOverrides.expiresAt, new Date())),
|
||||
),
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.externalId));
|
||||
}
|
||||
|
||||
async function getWatchNowExternalIds(userId: string, kind: CatalogKind) {
|
||||
if (kind === "music") {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ externalId: externalIds.externalId })
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.userId, userId),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
||||
eq(externalIds.source, "tmdb"),
|
||||
),
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => row.externalId));
|
||||
}
|
||||
|
||||
export default async function Home({ searchParams }: PageProps) {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const resolvedSearchParams = (await searchParams) ?? {};
|
||||
const kind = activeKind(resolvedSearchParams.tab);
|
||||
const query = singleParam(resolvedSearchParams.q)?.trim() ?? "";
|
||||
const collectionOnly = singleParam(resolvedSearchParams.collection) === "1";
|
||||
// The same flag the admin layout and the server actions check, rather than
|
||||
// re-deriving it from the group list: a button that appears under one rule and
|
||||
// an action that refuses under another is the worst version of this.
|
||||
const isAdmin = Boolean(session.user.isAdmin);
|
||||
|
||||
const [catalogItems, watchNowItems, watchNowExternalIds, purgedExternalIds, slotCount] = await Promise.all([
|
||||
getCatalogItems(kind, query),
|
||||
getWatchNow(session.user.id, kind),
|
||||
getWatchNowExternalIds(session.user.id, kind),
|
||||
isAdmin ? getPurgedExternalIds(kind) : Promise.resolve(new Set<string>()),
|
||||
getSlotCount(session.user.id, kind),
|
||||
]);
|
||||
|
||||
const collectionIds = await getCollectionIds(kind, catalogItems.map((item) => item.id));
|
||||
const visibleItems = collectionOnly
|
||||
? catalogItems.filter((item) => collectionIds.has(item.id))
|
||||
: catalogItems;
|
||||
|
||||
return (
|
||||
<main className="ampelos-app pb-44 text-ampelos-parchment">
|
||||
<header className="ampelos-topbar">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-5 md:flex-row md:items-center md:justify-between md:px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Image
|
||||
src="/ampelos.png"
|
||||
alt="Ampelos"
|
||||
width={64}
|
||||
height={64}
|
||||
priority
|
||||
className="h-14 w-14 shrink-0 rounded-full border border-ampelos-gold/35 bg-ampelos-ink/45 object-cover p-0.5 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-ampelos-gold">Ampelos</p>
|
||||
<h1 className="font-serif text-3xl font-semibold tracking-normal text-ampelos-parchment">Tend the vine. Set Dionysus's table.</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{isAdmin && (
|
||||
<Link href="/admin" className="ampelos-ghost-button px-3 py-2 font-medium">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }}>
|
||||
<button type="submit" className="ampelos-ghost-button px-3 py-2 font-medium">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-4 py-6 md:px-6">
|
||||
<nav className="flex gap-2 overflow-x-auto" aria-label="Media type">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.key === kind;
|
||||
return (
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={hrefFor({ tab: tab.key, q: query || null, collection: collectionOnly ? "1" : null })}
|
||||
className={
|
||||
active
|
||||
? "ampelos-stone-button px-4 py-2 text-sm"
|
||||
: "ampelos-ghost-button px-4 py-2 text-sm font-medium"
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<CatalogControls key={kind} kind={kind} query={query} collectionOnly={collectionOnly} />
|
||||
|
||||
<div className="mt-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold text-ampelos-parchment">
|
||||
{query ? "Results for “" + query + "”" : kind === "movies" ? "Popular movies" : kind === "television" ? "Popular television" : "Music"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-ampelos-muted">
|
||||
{collectionOnly
|
||||
? "Showing titles Ampelos already recognizes as part of the local collection."
|
||||
: "Popular picks are sourced from TMDB while search is open-ended."}
|
||||
</p>
|
||||
</div>
|
||||
{query && (
|
||||
<Link href={hrefFor({ tab: kind, q: null, collection: collectionOnly ? "1" : null })} className="text-sm font-medium text-ampelos-gold hover:text-ampelos-parchment">
|
||||
Clear search
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CatalogBoard
|
||||
kind={kind}
|
||||
visibleItems={visibleItems}
|
||||
collectionIds={[...collectionIds]}
|
||||
watchNowExternalIds={[...watchNowExternalIds]}
|
||||
purgedExternalIds={[...purgedExternalIds]}
|
||||
watchNowItems={watchNowItems}
|
||||
slotCount={slotCount}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user