Files
ampelos-dashboard/src/app/page.tsx
T
odin 1dfa2c0a04 Add the profile page, and publish it as an embeddable panel
Developed in a separate session; committed here alongside the calendar work
that shares this repository's migration journal.

A person can now see their own Plex link, their Watch Now slots and their watch
history at /profile. The same panel is published in two further forms so that
accounts.sticknife.com on charon can carry it as one section of a wider
sticknife profile, next to the other services' sections.

  - watch_history (0024) records what has been played, keyed on the Plex
    history id so a re-sync cannot duplicate a row. Partial unique index,
    because that id is null for anything entered by hand.
  - plex_accounts.is_server_owner (0025) marks the one account whose viewing
    the server files under local account 1 rather than under its plex.tv id.
  - The embed carries its own layout, origin allowlist and a frame-height
    reporter, so the host page can size it without guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:42:17 +02:00

288 lines
9.8 KiB
TypeScript

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";
// Set by the links on /profile, which point at a title rather than a tab.
const openExternalId = singleParam(resolvedSearchParams.open)?.trim() || null;
// 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&apos;s table.</h1>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<Link href="/profile" className="ampelos-ghost-button px-3 py-2 font-medium">
Profile
</Link>
{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}
openExternalId={openExternalId}
/>
</section>
</main>
);
}