From 07bbc115656dc1e702c5535b9dd2517ffa784c24 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Fri, 30 Oct 2026 07:18:26 -0700 Subject: [PATCH] Website header: the T5DE logo, four links, and a profile menu The header shows T5DE's logo (the client's, also the tab icon) and Online, Rooms, Products and Catalogs; the person's pages (Account, Library, Console for moderators) and signing out are in a menu under their picture: their first linked IMVU account's avatar picture, which /me now gives (kept an hour), or their initial. Creating a product moves to Products (New product, /products/new; /create redirects there), as creating a room is on Rooms. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 27 ++++++++- apps/api/src/imvu.ts | 2 + apps/api/test/age.test.ts | 2 +- apps/api/test/badges.test.ts | 2 +- apps/api/test/decorations.test.ts | 2 +- apps/api/test/hosted.test.ts | 4 +- apps/api/test/hub.test.ts | 12 ++-- apps/api/test/products.test.ts | 12 +++- apps/api/test/rooms.test.ts | 2 +- apps/web/app/create/page.tsx | 18 +----- apps/web/app/icon.png | Bin 0 -> 11384 bytes apps/web/app/icon.svg | 8 --- apps/web/app/products/new/page.tsx | 24 ++++++++ apps/web/app/products/page.tsx | 16 ++++-- apps/web/components/header-client.tsx | 77 +++++++++++++++++++++----- apps/web/components/header.tsx | 27 +++------ apps/web/components/t5de.png | Bin 0 -> 11384 bytes apps/web/lib/api.ts | 2 +- docs/api.md | 2 +- 19 files changed, 160 insertions(+), 79 deletions(-) create mode 100644 apps/web/app/icon.png delete mode 100644 apps/web/app/icon.svg create mode 100644 apps/web/app/products/new/page.tsx create mode 100644 apps/web/components/t5de.png diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7f569fc..896047d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import { Elysia, t } from "elysia"; import { and, + asc, desc, eq, ilike, @@ -151,6 +152,30 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms banReason: p.banReason ?? "", }); + /** A person's picture, for the website's header: their first linked IMVU account's avatar picture (null if none, + * or IMVU can't say). Kept an hour per account, so pages don't each ask IMVU. */ + const pictures = new Map(); + const picture = async (userId: string): Promise => { + const [first] = await db + .select({ imvuId: imvuLink.imvuId }) + .from(imvuLink) + .where(eq(imvuLink.userId, userId)) + .orderBy(asc(imvuLink.linkedAt)) + .limit(1); + if (!first) return null; + const kept = pictures.get(first.imvuId); + if (kept && Date.now() - kept.at < 60 * 60 * 1000) return kept.url; + let url: string | null = kept?.url ?? null; + try { + url = (await imvu.byId(first.imvuId)).picture; + } catch { + // IMVU unavailable: the last known one (or none), asked again next time. + return url; + } + pictures.set(first.imvuId, { url, at: Date.now() }); + return url; + }; + /** Links as the website shows them: with live presence, and age by the one rule. */ const linkViews = async (userId: string, links: Link[]) => { const ids = links.map((l) => l.imvuId); @@ -442,7 +467,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms }) // Who's signed in, and the home page. - .get("/me", ({ me }) => ({ person: personView(me) }), { signedIn: true }) + .get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { signedIn: true }) .get("/home", async ({ person }) => { const online = (await visibleOnline(person)).length; if (!person) return { onlineCount: online }; diff --git a/apps/api/src/imvu.ts b/apps/api/src/imvu.ts index d03f622..4608e2c 100644 --- a/apps/api/src/imvu.ts +++ b/apps/api/src/imvu.ts @@ -8,6 +8,7 @@ export type ImvuUser = { tagline: string; ageVerified: boolean; accessPass: boolean; + picture: string | null; // the avatar's picture (https, on *.imvu.com) }; export type ImvuRoom = { @@ -182,5 +183,6 @@ export function parse(answer: unknown): ImvuUser { tagline: typeof data.tagline === "string" ? data.tagline : "", ageVerified: data.is_ageverified === true, accessPass: data.is_ap === true, + picture: imvuImage(data.thumbnail_url), }; } diff --git a/apps/api/test/age.test.ts b/apps/api/test/age.test.ts index 89eeffa..455d983 100644 --- a/apps/api/test/age.test.ts +++ b/apps/api/test/age.test.ts @@ -78,7 +78,7 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?: const cookie = { v: "" }; await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name }); for (const a of ids) { - accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass }); + accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass, picture: null }); const started = await call(cookie, "POST", "/api/v1/account/links", { username: `${name}${a.id}` }); accounts.get(a.id)!.tagline = started.json.challenge.code; expect((await call(cookie, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); diff --git a/apps/api/test/badges.test.ts b/apps/api/test/badges.test.ts index 924f738..eb5c4ca 100644 --- a/apps/api/test/badges.test.ts +++ b/apps/api/test/badges.test.ts @@ -67,7 +67,7 @@ async function person(name: string, ...ids: number[]): Promise { expect(p.token).not.toBe(""); for (const [i, id] of ids.entries()) { const username = `${name}Av${i}`; - accounts.set(id, { id, username, tagline: "", ageVerified: false, accessPass: false }); + accounts.set(id, { id, username, tagline: "", ageVerified: false, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username }); accounts.get(id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${id}/check`)).status).toBe(200); diff --git a/apps/api/test/decorations.test.ts b/apps/api/test/decorations.test.ts index c7e802f..a221859 100644 --- a/apps/api/test/decorations.test.ts +++ b/apps/api/test/decorations.test.ts @@ -70,7 +70,7 @@ async function person(name: string, ...imvu: { id: number; ageVerified?: boolean p.id = (await call(p, "GET", "/api/v1/me")).json.person.id; for (const [i, a] of imvu.entries()) { const username = `${name}Av${i || ""}`; - accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false }); + accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username }); accounts.get(a.id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); diff --git a/apps/api/test/hosted.test.ts b/apps/api/test/hosted.test.ts index 52f0d3f..e67993f 100644 --- a/apps/api/test/hosted.test.ts +++ b/apps/api/test/hosted.test.ts @@ -66,7 +66,7 @@ async function person(name: string, ...imvu: { id: number; ageVerified?: boolean expect(p.token).not.toBe(""); for (const [i, a] of imvu.entries()) { const username = `${name}Av${i || ""}`; - accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false }); + accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username }); accounts.get(a.id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); @@ -330,7 +330,7 @@ test("moderators boot and ban; owners lift bans", async () => { const mod = await person("modr", { id: 602 }); const usr = await person("usrr", { id: 603 }); const md2 = await person("mod2", { id: 604 }); - accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false }); // IMVU only + accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false, picture: null }); // IMVU only const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n; // Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20. diff --git a/apps/api/test/hub.test.ts b/apps/api/test/hub.test.ts index 35e37d3..8e85724 100644 --- a/apps/api/test/hub.test.ts +++ b/apps/api/test/hub.test.ts @@ -130,14 +130,14 @@ describe("linking IMVU accounts", () => { test("by tagline; the most recent proof wins; link caps", async () => { const carol = new Browser(); await carol.signup("carol"); - accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false }); + accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false, picture: null }); const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" }); expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/); const missing = await carol.call("POST", "/api/v1/account/links/42/check"); expect(missing.status).toBe(409); expect(missing.json.error).toBe("code_missing"); - const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false }); + const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false, picture: null }); expect(linked.status).toBe(200); expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true }); expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe( @@ -148,7 +148,7 @@ describe("linking IMVU accounts", () => { const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!; const dave = new Browser(); await dave.signup("dave"); - const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false }); + const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false, picture: null }); expect(moved.json.moved).toBe(true); const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!; expect(after.userId).not.toBe(before.userId); @@ -160,7 +160,7 @@ describe("linking IMVU accounts", () => { await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" }); const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id; expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200); - const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false }); + const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false, picture: null }); expect(over.status).toBe(403); expect(over.json.error).toBe("link_cap"); }); @@ -170,7 +170,7 @@ describe("T5DE's proxy", () => { test("signs in with a device code, connects, shows presence; a ban ends it", async () => { const erin = new Browser(); await erin.signup("erin"); - await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false }); + await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false, picture: null }); // The proxy asks for a code; until it's approved, the token request waits. const codeRes = await fetch(`${base}/api/auth/device/code`, { @@ -216,7 +216,7 @@ describe("T5DE's proxy", () => { // An account linked while the client runs counts at once. ws.send(JSON.stringify({ type: "session", imvu_id: 78 })); await waitFor(() => messages.filter((m) => m.message === "not_linked").length === 2); - await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false }); + await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false, picture: null }); await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 78)); await erin.call("DELETE", "/api/v1/account/links/78"); await waitFor(() => !hub.realtime.online().some((o) => o.imvuId === 78)); diff --git a/apps/api/test/products.test.ts b/apps/api/test/products.test.ts index 6c73663..277263d 100644 --- a/apps/api/test/products.test.ts +++ b/apps/api/test/products.test.ts @@ -4,7 +4,7 @@ import { createLocalJWKSet, jwtVerify } from "jose"; import { openDatabase, type Database } from "@t5de/db"; import { createApp } from "../src/app"; import { createAuth } from "../src/auth"; -import { ImvuClient, imvuImage, parseProduct, type ImvuUser } from "../src/imvu"; +import { ImvuClient, imvuImage, parse, parseProduct, type ImvuUser } from "../src/imvu"; const webURL = "http://web.test"; const apiURL = "http://api.test"; @@ -77,7 +77,7 @@ async function person(name: string, imvu?: { id: number; ageVerified: boolean }) }); const p = { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") }; if (imvu) { - accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false }); + accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` }); accounts.get(imvu.id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200); @@ -270,3 +270,11 @@ test("derived products carry their IMVU parent's image and categories", async () expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200); expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image }); }); + +test("an IMVU account's avatar picture is kept only from IMVU's own hosts", () => { + const user = (thumbnail_url: unknown) => + parse({ denormalized: { "https://api.imvu.com/user/user-5": { data: { legacy_cid: 5, username: "Av", thumbnail_url } } } }); + expect(user("https://webasset-akm.imvu.com/resized_image/p.png").picture).toBe("https://webasset-akm.imvu.com/resized_image/p.png"); + expect(user("https://evil.example/p.png").picture).toBeNull(); + expect(user(undefined).picture).toBeNull(); +}); diff --git a/apps/api/test/rooms.test.ts b/apps/api/test/rooms.test.ts index a633923..a520604 100644 --- a/apps/api/test/rooms.test.ts +++ b/apps/api/test/rooms.test.ts @@ -88,7 +88,7 @@ async function person(name: string, imvu: { id: number; ageVerified?: boolean }) token: res.headers.get("set-auth-token") ?? "", }; expect(p.token).not.toBe(""); - accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false }); + accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` }); accounts.get(imvu.id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200); diff --git a/apps/web/app/create/page.tsx b/apps/web/app/create/page.tsx index bae8c3e..4c92e3f 100644 --- a/apps/web/app/create/page.tsx +++ b/apps/web/app/create/page.tsx @@ -1,18 +1,6 @@ -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. - - - ); +// Where creating a product used to be. +export default function Create() { + redirect("/products/new"); } diff --git a/apps/web/app/icon.png b/apps/web/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..058ffec8c8e7605c9d9279753a16313548ade4ce GIT binary patch literal 11384 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 literal 0 HcmV?d00001 diff --git a/apps/web/app/icon.svg b/apps/web/app/icon.svg deleted file mode 100644 index 90ca9c0..0000000 --- a/apps/web/app/icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - T5 - diff --git a/apps/web/app/products/new/page.tsx b/apps/web/app/products/new/page.tsx new file mode 100644 index 0000000..c235704 --- /dev/null +++ b/apps/web/app/products/new/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +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: "New product" }; + +export default async function NewProduct() { + const me = await getMe(); + if (!me) redirect("/signin?next=/products/new"); + return ( +
+ + Upload it to a catalog. You can edit its info after.{" "} + + Back to products + + + +
+ ); +} diff --git a/apps/web/app/products/page.tsx b/apps/web/app/products/page.tsx index f9ab59c..0a9baa9 100644 --- a/apps/web/app/products/page.tsx +++ b/apps/web/app/products/page.tsx @@ -1,20 +1,28 @@ import type { Metadata } from "next"; import { Empty, ProductCard } from "@/components/products"; +import Link from "next/link"; import { PageHeader } from "@/components/status"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { api, unwrap } from "@/lib/api"; +import { api, getMe, 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); + const [me, { products }] = await Promise.all([getMe(), api.products.get({ query: q ? { q } : {} }).then(unwrap)]); return ( <> - Community products for T5DE. +
+ Community products for T5DE. + {me && ( + + New product + + )} +
+ + + + {username} + + + + + + }> + Account + + }> + Library + + {console && ( + }> + Console + + )} + + { + setSigningOut(true); + await authClient.signOut(); + router.push("/"); + router.refresh(); + }} + > + {signingOut ? "Signing out…" : "Sign out"} + + + + + + ); +} + +/** A person's picture (their first linked IMVU account's avatar), or their initial. */ +function Avatar({ username, picture }: { username: string; picture: string | null }) { + const [failed, setFailed] = useState(false); + if (picture && !failed) { + return ( + setFailed(true)} className="size-7 rounded-full bg-muted object-cover object-top" /> + ); + } + return ( + + {username.slice(0, 1)} + ); } diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index a734312..deb45d9 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -1,17 +1,12 @@ +import Image from "next/image"; import Link from "next/link"; import { getMe, isModerator } from "@/lib/api"; import { buttonVariants } from "@/components/ui/button"; -import { NavLink, SignOutButton } from "./header-client"; +import { NavLink, ProfileMenu } from "./header-client"; +import logo from "./t5de.png"; -export function Logo() { - return ( - - T5 - - ); +export function Logo({ className }: { className?: string }) { + return T5DE; } export async function Header() { @@ -19,26 +14,18 @@ export async function Header() { return (
- + - T5DE Hub
{me ? ( - <> - {me.username} - - + ) : ( <> diff --git a/apps/web/components/t5de.png b/apps/web/components/t5de.png new file mode 100644 index 0000000000000000000000000000000000000000..058ffec8c8e7605c9d9279753a16313548ade4ce GIT binary patch literal 11384 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 literal 0 HcmV?d00001 diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 463b2dd..c79bd72 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -81,4 +81,4 @@ export const getMe = cache(async (): Promise => { return data && "person" in data ? data.person : undefined; }); -export const isModerator = (p: Person | undefined) => p?.role === "moderator" || p?.role === "admin"; +export const isModerator = (p: Pick | undefined) => p?.role === "moderator" || p?.role === "admin"; diff --git a/docs/api.md b/docs/api.md index 21c00ad..2734209 100644 --- a/docs/api.md +++ b/docs/api.md @@ -20,7 +20,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco | `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. | | `POST /api/auth/sign-in/username` | `{username, password}` | | `POST /api/auth/sign-out` | | -| `GET /api/v1/me` | `{person}` | +| `GET /api/v1/me` | `{person}`, with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` | ## Proxies