diff --git a/README.md b/README.md index 17ca903..69aa247 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de. proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs. - **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in use and the community products it wears. `/online` shows those who opted in, live, with the opt-in T5DE badge. +- **Products** (website): browsing and search, product pages, uploads straight from the browser to a catalog, + editing, revisions (retract, stay on one), the library, and registering catalogs (`docs/products.md`). Pages send + a strict Content Security Policy (`apps/web/proxy.ts`). - **Console** (moderators): counts, who's online (live), people, link caps, bans, roles, the audit log. ## Layout diff --git a/apps/web/app/catalogs/actions.ts b/apps/web/app/catalogs/actions.ts new file mode 100644 index 0000000..dd6939f --- /dev/null +++ b/apps/web/app/catalogs/actions.ts @@ -0,0 +1,26 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { api, ApiError, unwrap } from "@/lib/api"; +import { field } from "@/lib/actions"; + +export type RegisterState = { error?: string; registered?: { id: string; name: string; key: string } } | null; + +/** Registers a catalog; its key is in the answer, the only time it's shown. */ +export async function registerCatalog(_: RegisterState, form: FormData): Promise { + try { + const { catalog, key } = unwrap( + await api.catalogs.post({ + name: field(form, "name"), + url: field(form, "url"), + contact: field(form, "contact"), + visibility: field(form, "visibility"), + membership: field(form, "membership"), + }), + ); + revalidatePath("/catalogs"); + return { registered: { id: catalog.id, name: catalog.name, key } }; + } catch (e) { + if (e instanceof ApiError) return { error: e.message }; + throw e; + } +} diff --git a/apps/web/app/catalogs/page.tsx b/apps/web/app/catalogs/page.tsx new file mode 100644 index 0000000..7429909 --- /dev/null +++ b/apps/web/app/catalogs/page.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from "next"; +import { CatalogStatus, membership } from "@/components/catalogs"; +import { PageHeader } from "@/components/status"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { api, getMe, unwrap, type Catalog } from "@/lib/api"; +import { RegisterForm } from "./register-form"; + +export const metadata: Metadata = { title: "Catalogs" }; + +export default async function Catalogs() { + const [me, { catalogs }] = await Promise.all([getMe(), api.catalogs.get().then(unwrap)]); + const mine = catalogs.filter((c) => c.mine); + const directory = catalogs.filter((c) => !c.mine && c.status === "approved"); + + return ( +
+ Servers that host community products. + + {mine.length > 0 && ( + + + Yours + + + + + + )} + + + + Directory + + + {directory.length === 0 ? ( +

No catalogs yet.

+ ) : ( + + )} +
+
+ + {me && ( + + + Register a catalog + Run t5de-catalog, then register its address here. + + + + + + )} +
+ ); +} + +function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; showStatus?: boolean }) { + return ( + + + + Name + Address + Membership + {showStatus && Visibility} + {showStatus && Status} + + + + {catalogs.map((c) => ( + + {c.name} + {c.url} + {membership[c.membership] ?? c.membership} + {showStatus && {c.visibility}} + {showStatus && ( + + + + )} + + ))} + +
+ ); +} diff --git a/apps/web/app/catalogs/register-form.tsx b/apps/web/app/catalogs/register-form.tsx new file mode 100644 index 0000000..f934f31 --- /dev/null +++ b/apps/web/app/catalogs/register-form.tsx @@ -0,0 +1,79 @@ +"use client"; +import { useActionState } from "react"; +import { CopyButton } from "@/components/copy-button"; +import { ErrorText, SubmitButton } from "@/components/forms"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { registerCatalog } from "./actions"; + +export function RegisterForm() { + const [state, action] = useActionState(registerCatalog, null); + if (state?.registered) { + const { id, name, key } = state.registered; + return ( + + {name} is registered + +

Put these in the catalog's settings. The key is shown only once. A moderator approves it next.

+ + +
+
+ ); + } + return ( +
+ +
+ + Name + + + + Address + + + + Takedown contact + + +
+ + Visibility + + Public + Private + + + + Membership + + Open + By approval + Invite only + + +
+
+
+ Register +
+ {state?.error && {state.error}} +
+
+ ); +} + +function KeyLine({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + + +
+ ); +} diff --git a/apps/web/app/console/actions.ts b/apps/web/app/console/actions.ts index 945c65f..9bc643a 100644 --- a/apps/web/app/console/actions.ts +++ b/apps/web/app/console/actions.ts @@ -42,3 +42,13 @@ export async function setRole(_: ActionState, form: FormData): Promise { + const id = field(form, "id"); + const decision = field(form, "decision") === "approve" ? "approve" : "reject"; + return attempt(async () => { + unwrap(await api.console.catalogs({ id })({ decision }).post()); + revalidatePath("/console/catalogs"); + revalidatePath("/catalogs"); + }); +} diff --git a/apps/web/app/console/catalogs/page.tsx b/apps/web/app/console/catalogs/page.tsx new file mode 100644 index 0000000..61648ee --- /dev/null +++ b/apps/web/app/console/catalogs/page.tsx @@ -0,0 +1,86 @@ +import type { Metadata } from "next"; +import { CatalogStatus, membership } from "@/components/catalogs"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { PageHeader } from "@/components/status"; +import { DateTime } from "@/components/time"; +import { Card, CardContent } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { api, unwrap } from "@/lib/api"; +import { decideCatalog } from "../actions"; +import { ConsoleNav, requireModerator } from "../console-nav"; + +export const metadata: Metadata = { title: "Catalogs" }; + +export default async function ConsoleCatalogs() { + await requireModerator("/console/catalogs"); + const { catalogs } = unwrap(await api.console.catalogs.get()); + + return ( + <> + + + + + {catalogs.length === 0 ? ( +

None registered.

+ ) : ( + + + + Name + Address + Takedown contact + Access + Registered + Status + + + + + {catalogs.map((c) => ( + + {c.name} + {c.url} + {c.contact} + + {c.visibility}, {membership[c.membership]?.toLowerCase() ?? c.membership} + + + + + + + + + +
+ {c.status !== "approved" && ( + + Approve + + )} + {c.status !== "rejected" && ( + + Reject + + )} +
+
+
+
+ ))} +
+
+ )} +
+
+ + ); +} diff --git a/apps/web/app/console/console-nav.tsx b/apps/web/app/console/console-nav.tsx index 41ec5a8..6c7fd78 100644 --- a/apps/web/app/console/console-nav.tsx +++ b/apps/web/app/console/console-nav.tsx @@ -17,6 +17,7 @@ export function ConsoleNav() { Overview People + Catalogs ); } diff --git a/apps/web/app/create/page.tsx b/apps/web/app/create/page.tsx new file mode 100644 index 0000000..bae8c3e --- /dev/null +++ b/apps/web/app/create/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { PageHeader } from "@/components/status"; +import { UploadCard } from "@/components/upload-card"; +import { getMe } from "@/lib/api"; + +export const metadata: Metadata = { title: "Create" }; + +export default async function Create() { + const me = await getMe(); + if (!me) redirect("/signin?next=/create"); + return ( + <> + Upload it to a catalog. You can edit its info after. + + + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 4487f81..a1ab484 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -128,4 +128,37 @@ html { @apply font-sans; } -} \ No newline at end of file +} +/* Product descriptions: creators' HTML (cleaned by the API), laid out plainly. */ +@layer components { + .product-description { + @apply space-y-3 text-sm leading-relaxed break-words; + } + .product-description :where(h1, h2, h3, h4, h5, h6) { + @apply font-semibold; + } + .product-description :where(h1) { + @apply text-xl; + } + .product-description :where(h2) { + @apply text-lg; + } + .product-description :where(a) { + @apply underline underline-offset-4; + } + .product-description :where(ul) { + @apply list-disc pl-5; + } + .product-description :where(ol) { + @apply list-decimal pl-5; + } + .product-description :where(img) { + @apply inline-block h-auto max-w-full; + } + .product-description :where(td, th) { + @apply border px-2 py-1; + } + .product-description :where(hr) { + @apply my-4; + } +} diff --git a/apps/web/app/library/page.tsx b/apps/web/app/library/page.tsx new file mode 100644 index 0000000..73f59c1 --- /dev/null +++ b/apps/web/app/library/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { Empty, ProductCard } from "@/components/products"; +import { PageHeader } from "@/components/status"; +import { api, getMe, unwrap } from "@/lib/api"; +import { libraryAction } from "../products/actions"; + +export const metadata: Metadata = { title: "Library" }; + +export default async function Library() { + const me = await getMe(); + if (!me) redirect("/signin?next=/library"); + const { library } = await api.library.get().then(unwrap); + + return ( + <> + Products your T5DE uses, on all your IMVU accounts. + {library.length === 0 ? ( + + Nothing yet.{" "} + + Browse products + + . + + ) : ( +
    + {library.map(({ product, stayOn }) => ( +
  • + + {stayOn !== null &&

    Staying on revision {stayOn}

    } + + + Remove + + +
    +
  • + ))} +
+ )} + + ); +} diff --git a/apps/web/app/products/[id]/edit/page.tsx b/apps/web/app/products/[id]/edit/page.tsx new file mode 100644 index 0000000..4ce54c8 --- /dev/null +++ b/apps/web/app/products/[id]/edit/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { PageHeader } from "@/components/status"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { Textarea } from "@/components/ui/textarea"; +import { UploadCard } from "@/components/upload-card"; +import { getMe } from "@/lib/api"; +import { saveProduct } from "../../actions"; +import { loadProduct } from "../../load"; + +export const metadata: Metadata = { title: "Edit product" }; + +export default async function EditProduct({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const me = await getMe(); + if (!me) redirect(`/signin?next=/products/${id}/edit`); + const { product, mine } = await loadProduct(id); + if (!mine) notFound(); + + return ( +
+ + + Back to the product + + + + + + Info + + + + + + Name + + + + Description +