Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d57674d90c | ||
|
|
51d5bb5ff3 |
No files matched your search
@@ -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.
|
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
|
- **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.
|
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.
|
- **Console** (moderators): counts, who's online (live), people, link caps, bans, roles, the audit log.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|||||||
+1
-1
@@ -704,7 +704,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
"/library",
|
"/library",
|
||||||
async ({ me }) => ({
|
async ({ me }) => ({
|
||||||
library: (await products.library(me.id)).map((l) => ({
|
library: (await products.library(me.id)).map((l) => ({
|
||||||
product: productView(l.product),
|
product: productView(l.product, l.creator),
|
||||||
stayOn: l.stayOn,
|
stayOn: l.stayOn,
|
||||||
addedAt: l.addedAt.toISOString(),
|
addedAt: l.addedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ const options: sanitize.IOptions = {
|
|||||||
attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" },
|
attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Images whose address was refused are dropped.
|
// Images need an absolute https address (scheme rules don't apply to relative ones); others are dropped.
|
||||||
exclusiveFilter: (frame) => frame.tag === "img" && !frame.attribs.src,
|
exclusiveFilter: (frame) => frame.tag === "img" && !/^https:\/\//i.test(frame.attribs.src ?? ""),
|
||||||
disallowedTagsMode: "discard",
|
disallowedTagsMode: "discard",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -271,16 +271,9 @@ export class Products {
|
|||||||
}
|
}
|
||||||
if (input.rating !== undefined) {
|
if (input.rating !== undefined) {
|
||||||
if (input.rating === "adult") set.rating = "adult";
|
if (input.rating === "adult") set.rating = "adult";
|
||||||
else if (input.rating === "general" && p.rating === "adult") {
|
else if (input.rating === "general" && p.rating === "adult")
|
||||||
// Lowering is reviewed first (design.md); refused for now unless every revision is general.
|
// Lowering is reviewed by moderators first (design.md); until that exists, it's refused.
|
||||||
const [adultRev] = await this.db
|
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
|
||||||
.select({ n: productRevision.number })
|
|
||||||
.from(productRevision)
|
|
||||||
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult")))
|
|
||||||
.limit(1);
|
|
||||||
if (adultRev) throw new ProductError(400, "invalid", "A product with adult revisions stays adult.");
|
|
||||||
set.rating = "general";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
|
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
|
||||||
return updated!;
|
return updated!;
|
||||||
@@ -301,9 +294,10 @@ export class Products {
|
|||||||
|
|
||||||
async library(personId: string) {
|
async library(personId: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.select({ product, stayOn: library.stayOn, addedAt: library.addedAt })
|
.select({ product, stayOn: library.stayOn, addedAt: library.addedAt, creator: user.displayUsername })
|
||||||
.from(library)
|
.from(library)
|
||||||
.innerJoin(product, eq(product.id, library.productId))
|
.innerJoin(product, eq(product.id, library.productId))
|
||||||
|
.innerJoin(user, eq(user.id, product.creatorId))
|
||||||
.where(eq(library.userId, personId))
|
.where(eq(library.userId, personId))
|
||||||
.orderBy(desc(library.addedAt));
|
.orderBy(desc(library.addedAt));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,11 +119,11 @@ test("catalogs register uploads; products have revisions, info and libraries", a
|
|||||||
|
|
||||||
// Descriptions are cleaned by the allowlist.
|
// Descriptions are cleaned by the allowlist.
|
||||||
const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, {
|
const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, {
|
||||||
description: `<p style="color:red;position:fixed">Warm <b>coat</b><script>alert(1)</script><a href="javascript:x">x</a><a href="https://example.test" onclick="x()">site</a><img src="http://x/a.png"></p>`,
|
description: `<p style="color:red;position:fixed">Warm <b>coat</b><script>alert(1)</script><a href="javascript:x">x</a><a href="https://example.test" onclick="x()">site</a><img src="http://x/a.png"><img src="relative.png"><img src="https://img.test/a.png"></p>`,
|
||||||
});
|
});
|
||||||
expect(edited.status).toBe(200);
|
expect(edited.status).toBe(200);
|
||||||
expect(edited.json.product.description).toBe(
|
expect(edited.json.product.description).toBe(
|
||||||
`<p style="color:red">Warm <b>coat</b><a rel="nofollow ugc noopener noreferrer" target="_blank">x</a><a href="https://example.test" rel="nofollow ugc noopener noreferrer" target="_blank">site</a></p>`,
|
`<p style="color:red">Warm <b>coat</b><a rel="nofollow ugc noopener noreferrer" target="_blank">x</a><a href="https://example.test" rel="nofollow ugc noopener noreferrer" target="_blank">site</a><img src="https://img.test/a.png" /></p>`,
|
||||||
);
|
);
|
||||||
expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403);
|
expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403);
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ test("catalogs register uploads; products have revisions, info and libraries", a
|
|||||||
|
|
||||||
// The library: get it, the proxy resolves it to the latest, or the revision the person stays on.
|
// The library: get it, the proxy resolves it to the latest, or the revision the person stays on.
|
||||||
expect((await call(kid, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
|
expect((await call(kid, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
|
||||||
expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
|
expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => [l.product.id, l.product.creator])).toEqual([[id, "maker"]]);
|
||||||
let files2 = (await call(kid, "GET", `/api/v1/products/${id}/files`)).json;
|
let files2 = (await call(kid, "GET", `/api/v1/products/${id}/files`)).json;
|
||||||
expect(files2).toMatchObject({ product: id, revision: 2, parent: 500, name: "Blue Coat", creator: "maker", catalogs: ["http://localhost:4102"] });
|
expect(files2).toMatchObject({ product: id, revision: 2, parent: 500, name: "Blue Coat", creator: "maker", catalogs: ["http://localhost:4102"] });
|
||||||
expect((await call(kid, "PATCH", `/api/v1/library/${id}`, { stayOn: 1 })).status).toBe(200);
|
expect((await call(kid, "PATCH", `/api/v1/library/${id}`, { stayOn: 1 })).status).toBe(200);
|
||||||
@@ -165,6 +165,9 @@ test("catalogs register uploads; products have revisions, info and libraries", a
|
|||||||
expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]);
|
expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]);
|
||||||
expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]);
|
expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]);
|
||||||
|
|
||||||
|
// Lowering a rating waits for review.
|
||||||
|
expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("review_needed");
|
||||||
|
|
||||||
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
|
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
|
||||||
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
|
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -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<RegisterState> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="Catalogs">Servers that host community products.</PageHeader>
|
||||||
|
|
||||||
|
{mine.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Yours</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CatalogTable catalogs={mine} showStatus />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Directory</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{directory.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No catalogs yet.</p>
|
||||||
|
) : (
|
||||||
|
<CatalogTable catalogs={directory} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{me && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Register a catalog</CardTitle>
|
||||||
|
<CardDescription>Run t5de-catalog, then register its address here.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RegisterForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; showStatus?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Address</TableHead>
|
||||||
|
<TableHead>Membership</TableHead>
|
||||||
|
{showStatus && <TableHead>Visibility</TableHead>}
|
||||||
|
{showStatus && <TableHead>Status</TableHead>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{catalogs.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell>
|
||||||
|
<TableCell>{membership[c.membership] ?? c.membership}</TableCell>
|
||||||
|
{showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>}
|
||||||
|
{showStatus && (
|
||||||
|
<TableCell>
|
||||||
|
<CatalogStatus status={c.status} />
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>{name} is registered</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-3">
|
||||||
|
<p>Put these in the catalog's settings. The key is shown only once. A moderator approves it next.</p>
|
||||||
|
<KeyLine label="CATALOG_ID" value={id} />
|
||||||
|
<KeyLine label="CATALOG_KEY" value={key} />
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<form action={action}>
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||||
|
<Input id="name" name="name" required maxLength={64} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="url">Address</FieldLabel>
|
||||||
|
<Input id="url" name="url" type="url" required maxLength={300} placeholder="https://" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
|
||||||
|
<Input id="contact" name="contact" required maxLength={200} placeholder="Email or URL" />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="visibility">Visibility</FieldLabel>
|
||||||
|
<NativeSelect id="visibility" name="visibility" defaultValue="public" className="w-full">
|
||||||
|
<NativeSelectOption value="public">Public</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="private">Private</NativeSelectOption>
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="membership">Membership</FieldLabel>
|
||||||
|
<NativeSelect id="membership" name="membership" defaultValue="open" className="w-full">
|
||||||
|
<NativeSelectOption value="open">Open</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="approval">By approval</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="invite">Invite only</NativeSelectOption>
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SubmitButton pendingLabel="Registering…">Register</SubmitButton>
|
||||||
|
</div>
|
||||||
|
{state?.error && <ErrorText>{state.error}</ErrorText>}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyLine({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="w-28 font-mono text-xs text-muted-foreground">{label}</span>
|
||||||
|
<code className="min-w-0 rounded-md border bg-muted px-2 py-1 font-mono text-xs break-all text-foreground">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={value} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,3 +42,13 @@ export async function setRole(_: ActionState, form: FormData): Promise<ActionSta
|
|||||||
return `Role set to ${person.role}.`;
|
return `Role set to ${person.role}.`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function decideCatalog(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Catalogs" />
|
||||||
|
<ConsoleNav />
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{catalogs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">None registered.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Address</TableHead>
|
||||||
|
<TableHead>Takedown contact</TableHead>
|
||||||
|
<TableHead>Access</TableHead>
|
||||||
|
<TableHead>Registered</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{catalogs.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{c.url}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.contact}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={c.createdAt} dateOnly />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<CatalogStatus status={c.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{c.status !== "approved" && (
|
||||||
|
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
|
||||||
|
Approve
|
||||||
|
</SubmitButton>
|
||||||
|
)}
|
||||||
|
{c.status !== "rejected" && (
|
||||||
|
<SubmitButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
name="decision"
|
||||||
|
value="reject"
|
||||||
|
pendingLabel="Rejecting…"
|
||||||
|
confirm={`Reject ${c.name}?`}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</SubmitButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export function ConsoleNav() {
|
|||||||
Overview
|
Overview
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/console/people">People</NavLink>
|
<NavLink href="/console/people">People</NavLink>
|
||||||
|
<NavLink href="/console/catalogs">Catalogs</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Create a product">Upload it to a catalog. You can edit its info after.</PageHeader>
|
||||||
|
<UploadCard title="Upload" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -128,4 +128,37 @@
|
|||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Your library">Products your T5DE uses, on all your IMVU accounts.</PageHeader>
|
||||||
|
{library.length === 0 ? (
|
||||||
|
<Empty>
|
||||||
|
Nothing yet.{" "}
|
||||||
|
<Link href="/products" className="text-foreground underline underline-offset-4">
|
||||||
|
Browse products
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{library.map(({ product, stayOn }) => (
|
||||||
|
<li key={product.id}>
|
||||||
|
<ProductCard product={product}>
|
||||||
|
{stayOn !== null && <p className="text-xs">Staying on revision {stayOn}</p>}
|
||||||
|
<ActionForm action={libraryAction} fields={{ id: product.id }} className="pt-1">
|
||||||
|
<SubmitButton size="sm" variant="outline" name="intent" value="remove" pendingLabel="Removing…">
|
||||||
|
Remove
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</ProductCard>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title={`Edit ${product.name}`}>
|
||||||
|
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
|
||||||
|
Back to the product
|
||||||
|
</Link>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Info</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={saveProduct} fields={{ id }}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||||
|
<Input id="name" name="name" defaultValue={product.name} required maxLength={64} className="max-w-md" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="description">Description</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
defaultValue={product.description}
|
||||||
|
maxLength={20000}
|
||||||
|
rows={8}
|
||||||
|
className="font-mono text-xs md:text-xs"
|
||||||
|
/>
|
||||||
|
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="rating">Rating</FieldLabel>
|
||||||
|
{product.rating === "adult" ? (
|
||||||
|
<p className="text-sm">Adult</p>
|
||||||
|
) : (
|
||||||
|
<NativeSelect id="rating" name="rating" defaultValue="general" className="w-48">
|
||||||
|
<NativeSelectOption value="general">General</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="adult">Adult</NativeSelectOption>
|
||||||
|
</NativeSelect>
|
||||||
|
)}
|
||||||
|
<FieldDescription>Raising it to adult is immediate; lowering it needs review.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<div>
|
||||||
|
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<UploadCard title="Upload a new revision" product={id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { formatSize, RatingTag } from "@/components/products";
|
||||||
|
import { PageHeader, Tag } from "@/components/status";
|
||||||
|
import { DateTime } from "@/components/time";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { getMe, type Revision } from "@/lib/api";
|
||||||
|
import { libraryAction, retract } from "../actions";
|
||||||
|
import { loadProduct } from "../load";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
const { product } = await loadProduct((await params).id);
|
||||||
|
return { title: product.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const [me, data] = await Promise.all([getMe(), loadProduct(id)]);
|
||||||
|
const { product, revisions, inLibrary, stayOn, mine } = data;
|
||||||
|
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title={product.name}>
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
{product.creator && <span>by {product.creator}</span>}
|
||||||
|
<RatingTag rating={product.rating} />
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{me ? (
|
||||||
|
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{inLibrary ? (
|
||||||
|
<>
|
||||||
|
<Tag tone="strong">In your library</Tag>
|
||||||
|
<SubmitButton variant="outline" name="intent" value="remove" pendingLabel="Removing…">
|
||||||
|
Remove
|
||||||
|
</SubmitButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<SubmitButton name="intent" value="get" pendingLabel="Getting…">
|
||||||
|
Get
|
||||||
|
</SubmitButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
) : (
|
||||||
|
<Link href={`/signin?next=/products/${id}`} className={buttonVariants()}>
|
||||||
|
Sign in to get it
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{mine && (
|
||||||
|
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{product.description && (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{/* Cleaned by the API's allowlist when saved; the page's CSP blocks scripts besides. */}
|
||||||
|
<div className="product-description" dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Revisions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{revisions.map((r) => (
|
||||||
|
<RevisionRow
|
||||||
|
key={r.number}
|
||||||
|
id={id}
|
||||||
|
revision={r}
|
||||||
|
latest={r.number === product.latest}
|
||||||
|
inLibrary={inLibrary}
|
||||||
|
stayOn={stayOn}
|
||||||
|
using={using === r.number}
|
||||||
|
mine={mine}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The revision T5DE uses (as the API's revisionFor): want, else the nearest earlier one not retracted, else later. */
|
||||||
|
function inUse(revisions: Revision[], want: number): number | undefined {
|
||||||
|
const usable = revisions.filter((r) => !r.retracted); // newest first
|
||||||
|
return (usable.find((r) => r.number === want) ?? usable.find((r) => r.number < want) ?? usable.at(-1))?.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevisionRow({
|
||||||
|
id,
|
||||||
|
revision: r,
|
||||||
|
latest,
|
||||||
|
inLibrary,
|
||||||
|
stayOn,
|
||||||
|
using,
|
||||||
|
mine,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
revision: Revision;
|
||||||
|
latest: boolean;
|
||||||
|
inLibrary: boolean;
|
||||||
|
stayOn: number | null;
|
||||||
|
using: boolean;
|
||||||
|
mine: boolean;
|
||||||
|
}) {
|
||||||
|
const facts = [
|
||||||
|
formatSize(r.size),
|
||||||
|
`${r.files.length} ${r.files.length === 1 ? "file" : "files"}`,
|
||||||
|
r.catalogs.length > 0 ? r.catalogs.join(", ") : "no catalog",
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<li className="space-y-2 py-4 first:pt-0 last:pb-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">Revision {r.number}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
<DateTime iso={r.createdAt} dateOnly />
|
||||||
|
</span>
|
||||||
|
{latest && <Tag>latest</Tag>}
|
||||||
|
<RatingTag rating={r.rating} />
|
||||||
|
{r.retracted && <Tag tone="danger">retracted</Tag>}
|
||||||
|
{using && <Tag tone="strong">{stayOn === r.number ? "staying on this" : "in use"}</Tag>}
|
||||||
|
</div>
|
||||||
|
{r.notes && <p className="text-sm whitespace-pre-line">{r.notes}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">{facts.join(" · ")}</p>
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Files</summary>
|
||||||
|
<ul className="mt-2 space-y-1 font-mono text-xs">
|
||||||
|
{r.files.map((f) => (
|
||||||
|
<li key={f.name} className="flex gap-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">{formatSize(f.size)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{((inLibrary && !r.retracted) || (mine && !r.retracted)) && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{inLibrary && !r.retracted && (
|
||||||
|
<ActionForm action={libraryAction} fields={{ id, revision: r.number }}>
|
||||||
|
{stayOn === r.number ? (
|
||||||
|
<SubmitButton size="sm" variant="outline" name="intent" value="follow" pendingLabel="Saving…">
|
||||||
|
Follow latest
|
||||||
|
</SubmitButton>
|
||||||
|
) : (
|
||||||
|
<SubmitButton size="sm" variant="outline" name="intent" value="stay" pendingLabel="Saving…">
|
||||||
|
Stay on this revision
|
||||||
|
</SubmitButton>
|
||||||
|
)}
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
{mine && !r.retracted && (
|
||||||
|
<ActionForm action={retract} fields={{ id, revision: r.number }}>
|
||||||
|
<SubmitButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
pendingLabel="Retracting…"
|
||||||
|
confirm={`Retract revision ${r.number}? T5DE stops using it.`}
|
||||||
|
>
|
||||||
|
Retract
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { api, ApiError, unwrap } from "@/lib/api";
|
||||||
|
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||||
|
|
||||||
|
const done = (id: string) => {
|
||||||
|
revalidatePath(`/products/${id}`);
|
||||||
|
revalidatePath("/library");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The person's library and a product: get it, remove it, stay on a revision or follow the latest. */
|
||||||
|
export async function libraryAction(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const id = field(form, "id");
|
||||||
|
return attempt(async () => {
|
||||||
|
const lib = api.library({ id });
|
||||||
|
switch (field(form, "intent")) {
|
||||||
|
case "get":
|
||||||
|
unwrap(await lib.put());
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
unwrap(await lib.delete());
|
||||||
|
break;
|
||||||
|
case "stay":
|
||||||
|
unwrap(await lib.patch({ stayOn: Number(field(form, "revision")) }));
|
||||||
|
break;
|
||||||
|
case "follow":
|
||||||
|
unwrap(await lib.patch({ stayOn: null }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
done(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const id = field(form, "id");
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.products({ id }).revisions({ n: Number(field(form, "revision")) }).retract.post());
|
||||||
|
done(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProduct(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const id = field(form, "id");
|
||||||
|
const rating = field(form, "rating");
|
||||||
|
const state = await attempt(async () => {
|
||||||
|
unwrap(
|
||||||
|
await api.products({ id }).patch({
|
||||||
|
name: field(form, "name"),
|
||||||
|
description: typeof form.get("description") === "string" ? String(form.get("description")) : "",
|
||||||
|
...(rating === "adult" ? { rating: "adult" as const } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
done(id);
|
||||||
|
revalidatePath("/products");
|
||||||
|
});
|
||||||
|
if (state?.error) return state;
|
||||||
|
redirect(`/products/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An upload token and the catalog's upload address, for the browser to send the file there itself. */
|
||||||
|
export async function startUpload(
|
||||||
|
catalog: string,
|
||||||
|
product?: string,
|
||||||
|
): Promise<{ token: string; url: string } | { error: string }> {
|
||||||
|
try {
|
||||||
|
return unwrap(await api.products.upload.post({ catalog, ...(product ? { product } : {}) }));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.message };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { api, ApiError, unwrap, type ProductPage } from "@/lib/api";
|
||||||
|
|
||||||
|
/** The product, or not found (also when it's adult and the visitor can't see adult products). */
|
||||||
|
export async function loadProduct(id: string): Promise<ProductPage> {
|
||||||
|
if (!/^[a-z2-7]{10}$/.test(id)) notFound();
|
||||||
|
try {
|
||||||
|
return unwrap(await api.products({ id }).get());
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 404) notFound();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Empty, ProductCard } from "@/components/products";
|
||||||
|
import { PageHeader } from "@/components/status";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Products" };
|
||||||
|
|
||||||
|
export default async function Products({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
|
||||||
|
const raw = (await searchParams).q;
|
||||||
|
const q = typeof raw === "string" ? raw.trim().slice(0, 100) : "";
|
||||||
|
const { products } = await api.products.get({ query: q ? { q } : {} }).then(unwrap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Products">Community products for T5DE.</PageHeader>
|
||||||
|
<form action="/products" className="mb-6 flex w-full max-w-md gap-2">
|
||||||
|
<Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" />
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<Empty>{q ? "Nothing found." : "No products yet."}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{products.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<ProductCard product={p} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Tag } from "@/components/status";
|
||||||
|
|
||||||
|
export const membership: Record<string, string> = { open: "Open", approval: "By approval", invite: "Invite only" };
|
||||||
|
|
||||||
|
/** A catalog's review status: approved, pending or rejected. */
|
||||||
|
export function CatalogStatus({ status }: { status: string }) {
|
||||||
|
if (status === "approved") return <Tag tone="strong">approved</Tag>;
|
||||||
|
if (status === "rejected") return <Tag tone="danger">rejected</Tag>;
|
||||||
|
return <Tag>{status}</Tag>;
|
||||||
|
}
|
||||||
@@ -23,9 +23,13 @@ export async function Header() {
|
|||||||
<Logo />
|
<Logo />
|
||||||
T5DE Hub
|
T5DE Hub
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto">
|
<nav className="order-last flex w-full flex-wrap items-center gap-1 text-sm sm:order-none sm:w-auto">
|
||||||
<NavLink href="/online">Online</NavLink>
|
<NavLink href="/online">Online</NavLink>
|
||||||
<NavLink href="/rooms">Rooms</NavLink>
|
<NavLink href="/rooms">Rooms</NavLink>
|
||||||
|
<NavLink href="/products">Products</NavLink>
|
||||||
|
<NavLink href="/catalogs">Catalogs</NavLink>
|
||||||
|
{me && <NavLink href="/library">Library</NavLink>}
|
||||||
|
{me && <NavLink href="/create">Create</NavLink>}
|
||||||
{me && <NavLink href="/account">Account</NavLink>}
|
{me && <NavLink href="/account">Account</NavLink>}
|
||||||
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
|
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Pieces shared by the product pages.
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Tag } from "@/components/status";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import type { ProductSummary } from "@/lib/api";
|
||||||
|
|
||||||
|
export function RatingTag({ rating }: { rating: string }) {
|
||||||
|
return rating === "adult" ? <Tag tone="adult">Adult</Tag> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1536 → "1.5 KB". */
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB"];
|
||||||
|
let n = bytes / 1024;
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) {
|
||||||
|
n /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductCard({
|
||||||
|
product,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest">;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline">
|
||||||
|
{product.name}
|
||||||
|
</Link>
|
||||||
|
<RatingTag rating={product.rating} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{[product.creator && `by ${product.creator}`, `revision ${product.latest}`].filter(Boolean).join(" · ")}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Empty({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<p className="py-6 text-sm text-muted-foreground">{children}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
|||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cn } from "cn"
|
import { cn } from "cn"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const variants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
@@ -39,16 +39,19 @@ const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Merged, so a variant's border replaces the base's transparent one on links styled as buttons too.
|
||||||
|
const buttonVariants = (props?: Parameters<typeof variants>[0]) => cn(variants(props))
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
...props
|
...props
|
||||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
}: ButtonPrimitive.Props & VariantProps<typeof variants>) {
|
||||||
return (
|
return (
|
||||||
<ButtonPrimitive
|
<ButtonPrimitive
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={buttonVariants({ variant, size, className })}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { UploadForm } from "@/components/upload-form";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
|
||||||
|
/** Uploading to one of the approved catalogs: a new product, or with product, a new revision of it. */
|
||||||
|
export async function UploadCard({ title, product }: { title: string; product?: string }) {
|
||||||
|
const { catalogs } = await api.catalogs.get().then(unwrap);
|
||||||
|
const approved = catalogs.filter((c) => c.status === "approved").map((c) => ({ id: c.id, name: c.name }));
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{approved.length > 0 ? (
|
||||||
|
<UploadForm catalogs={approved} product={product} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No catalogs to upload to yet. See{" "}
|
||||||
|
<Link href="/catalogs" className="text-foreground underline underline-offset-4">
|
||||||
|
catalogs
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
// Uploading a product (or a new revision of one): the hub gives an upload token, then the browser sends the file to
|
||||||
|
// the catalog itself, so files never pass through the website's server.
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ErrorText } from "@/components/forms";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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 { startUpload } from "@/app/products/actions";
|
||||||
|
|
||||||
|
type Option = { id: string; name: string };
|
||||||
|
|
||||||
|
/** Sends form to url with the token, reporting progress; resolves with the catalog's status and JSON answer. */
|
||||||
|
function send(url: string, token: string, form: FormData, progress: (fraction: number) => void) {
|
||||||
|
return new Promise<{ status: number; body: unknown }>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", url);
|
||||||
|
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||||
|
xhr.upload.onprogress = (e) => e.lengthComputable && progress(e.loaded / e.total);
|
||||||
|
xhr.onload = () => {
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(xhr.responseText);
|
||||||
|
} catch {}
|
||||||
|
resolve({ status: xhr.status, body });
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error("network"));
|
||||||
|
xhr.send(form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadForm({ catalogs, product }: { catalogs: Option[]; product?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [progress, setProgress] = useState<number | null>(null);
|
||||||
|
|
||||||
|
async function submit(form: FormData) {
|
||||||
|
setError("");
|
||||||
|
setProgress(0);
|
||||||
|
const start = await startUpload(String(form.get("catalog")), product);
|
||||||
|
if ("error" in start) {
|
||||||
|
setError(start.error);
|
||||||
|
setProgress(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = new FormData();
|
||||||
|
body.set("file", form.get("file") as File);
|
||||||
|
body.set("notes", String(form.get("notes") ?? ""));
|
||||||
|
try {
|
||||||
|
const answer = await send(start.url, start.token, body, setProgress);
|
||||||
|
const value = answer.body as { product?: unknown; message?: unknown } | null;
|
||||||
|
if (answer.status === 201 && typeof value?.product === "string") {
|
||||||
|
router.push(`/products/${value.product}`);
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(
|
||||||
|
typeof value?.message === "string" && value.message
|
||||||
|
? value.message
|
||||||
|
: `The catalog refused the upload (${answer.status}).`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setError("Couldn't reach the catalog. Try again in a moment.");
|
||||||
|
}
|
||||||
|
setProgress(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = progress !== null;
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit(new FormData(e.currentTarget));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="catalog">Catalog</FieldLabel>
|
||||||
|
<NativeSelect id="catalog" name="catalog" required className="w-full max-w-sm">
|
||||||
|
{catalogs.map((c) => (
|
||||||
|
<NativeSelectOption key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="file">File</FieldLabel>
|
||||||
|
<Input id="file" name="file" type="file" accept=".chkn,.zip" required className="max-w-sm" />
|
||||||
|
<FieldDescription>A Create Mode .chkn, or a zip of the product's files.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="notes">{product ? "What changed" : "Notes"}</FieldLabel>
|
||||||
|
<Textarea id="notes" name="notes" required={!!product} maxLength={4000} rows={3} />
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Uploading…" : "Upload"}
|
||||||
|
</Button>
|
||||||
|
{busy && (
|
||||||
|
<div
|
||||||
|
className="h-1.5 w-40 overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(progress * 100)}
|
||||||
|
>
|
||||||
|
<div className="h-full bg-foreground transition-[width]" style={{ width: `${progress * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,11 @@ export type Challenge = Account["challenges"][number];
|
|||||||
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
|
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
|
||||||
export type ConsoleData = Data<V1["console"]["get"]>;
|
export type ConsoleData = Data<V1["console"]["get"]>;
|
||||||
export type AuditEntry = ConsoleData["audit"][number];
|
export type AuditEntry = ConsoleData["audit"][number];
|
||||||
|
export type ProductSummary = Data<V1["products"]["get"]>["products"][number];
|
||||||
|
export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
|
||||||
|
export type Revision = ProductPage["revisions"][number];
|
||||||
|
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
|
||||||
|
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
|
||||||
|
|
||||||
/** An error the API answered: message is written to be shown as is. */
|
/** An error the API answered: message is written to be shown as is. */
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// A Content Security Policy for every page: scripts only from this site with the request's nonce (Next.js adds it to
|
||||||
|
// its own scripts), so HTML that slips into a page, such as a product's description, can't run anything. Uploads go
|
||||||
|
// from the browser straight to catalogs, so connect-src allows any https address (and local ones, for development).
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||||
|
const dev = process.env.NODE_ENV === "development";
|
||||||
|
const policy = [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${dev ? " 'unsafe-eval'" : ""}`,
|
||||||
|
// Descriptions keep their style attributes (the API's allowlist limits them to colors, sizes and alignment).
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' https: data: blob:",
|
||||||
|
"font-src 'self'",
|
||||||
|
`connect-src 'self' https: http://localhost:* http://127.0.0.1:*${dev ? " ws:" : ""}`,
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.set("x-nonce", nonce);
|
||||||
|
headers.set("Content-Security-Policy", policy);
|
||||||
|
const response = NextResponse.next({ request: { headers } });
|
||||||
|
response.headers.set("Content-Security-Policy", policy);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
{
|
||||||
|
source: "/((?!api|_next/static|_next/image|icon.svg|favicon.ico).*)",
|
||||||
|
missing: [
|
||||||
|
{ type: "header", key: "next-router-prefetch" },
|
||||||
|
{ type: "header", key: "purpose", value: "prefetch" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in new issue
Block a user