Files
t5de-hub/apps/web/app/catalogs/[id]/page.tsx
T
dhkatzandClaude Opus 5.5 8af4b7b6a9
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
feat: cases (reports, rating reviews, removal and deletion requests, appeals) and notifications
Cases are one queue for what someone has to decide, each with a thread (internal notes for those deciding), an
assignee and a decision. Reports about a product go to the teams of the catalogs hosting it and to moderators, and
only the reporter and moderators know who reported. A creator lowering a rating opens a rating review; a catalog's
owner asking to delete it opens a deletion request; a catalog's team can ask for a product's removal everywhere; the
person an action affected can appeal it once, decided by another moderator (an admin may decide their own, flagged).
Resolving carries the case out: the rating is lowered, the product removed, the catalog deleted.

Notifications reach people on the website (a bell with the unread count) and their proxies (realtime): moderators'
edits to their products, removals and restores, decisions and replies on their cases, membership decisions.

Also: moderators can restore a removed product, and a product's creator and moderators can see it when it's adult
without an adult account (getting and wearing it still need one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 07:03:59 -08:00

103 lines
4.4 KiB
TypeScript

import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ReportForm } from "@/components/cases";
import { membership as membershipLabel } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { api, getMe, unwrap } from "@/lib/api";
import { membership } from "../../console/catalogs/actions";
export const metadata: Metadata = { title: "Catalog" };
export default async function CatalogPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ invite?: string | string[] }>;
}) {
const { id } = await params;
const raw = (await searchParams).invite;
const invite = typeof raw === "string" && /^[a-z0-9]{1,32}$/.test(raw) ? raw : "";
const [me, answer] = await Promise.all([getMe(), api.catalogs({ id }).get()]);
if (answer.error) notFound();
const data = unwrap(answer);
const c = data.catalog;
const manage = data.permissions.includes("audit:read");
return (
<div className="space-y-6">
<PageHeader title={c.name}>
<span className="flex flex-wrap items-center gap-2">
<span>{membershipLabel[c.membership] ?? c.membership}</span>
{c.maxRating === "general" && <Tag>General audiences</Tag>}
{data.membership === "member" && <Tag tone="strong">Member</Tag>}
{data.membership === "requested" && <Tag>Asked to join</Tag>}
{data.membership === "banned" && <Tag tone="danger">Banned</Tag>}
</span>
</PageHeader>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Products</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{data.hosted.products}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Takedown Contact</CardDescription>
<CardTitle className="text-base font-normal break-all">{c.contact}</CardTitle>
</CardHeader>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Membership</CardTitle>
<CardDescription>
{c.membership === "open"
? "Anyone on T5DE can get its products."
: c.membership === "approval"
? "Ask to join; its team decides. Anyone can see its products on others."
: "Join with an invitation. Anyone can see its products on others."}
</CardDescription>
</CardHeader>
<CardContent>
{!me ? (
<Link href={`/signin?next=${encodeURIComponent(`/catalogs/${id}${invite ? `?invite=${invite}` : ""}`)}`} className={buttonVariants()}>
Sign in to join
</Link>
) : data.membership === "banned" ? (
<p className="text-sm text-muted-foreground">This catalog&apos;s team banned you from it.</p>
) : data.membership === "member" || data.membership === "requested" ? (
<ActionForm action={membership} fields={{ id, intent: "leave" }}>
<SubmitButton variant="outline" pendingLabel="Leaving…" confirm={data.membership === "member" ? `Leave ${c.name}?` : undefined}>
{data.membership === "member" ? "Leave" : "Withdraw request"}
</SubmitButton>
</ActionForm>
) : c.membership === "open" ? (
<p className="text-sm text-muted-foreground">Nothing to join: get its products from their pages.</p>
) : c.membership === "invite" && !invite ? (
<p className="text-sm text-muted-foreground">You need an invitation link from its team.</p>
) : (
<ActionForm action={membership} fields={{ id, intent: "join", invite }}>
<SubmitButton pendingLabel="Joining…">{invite ? "Accept invitation" : "Ask to join"}</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
{manage && (
<Link href={`/console/catalogs/${id}`} className={buttonVariants({ variant: "outline" })}>
Manage
</Link>
)}
{me && !manage && <ReportForm about="catalog" id={id} />}
</div>
);
}