From 64c552330b38ef0b33d9d33be3aaea45ecad0ad3 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 29 Oct 2026 05:02:00 -0700 Subject: [PATCH] Website: Next.js app for accounts, linking, who's online, device sign-in and the console apps/web: Next.js 16 (App Router), Tailwind 4 and shadcn/ui, light and dark from the system. Pages render on the server and call the API with Eden Treaty (typed by @t5de/api), forwarding the visitor's cookie; forms use server actions; sign-up, sign-in and sign-out use Better Auth's React client. /api is rewritten to the API so its cookies are first-party. Who's online follows the API's event stream (Next's gzip is off: it held the stream back). Standalone output with a Dockerfile; deploys to Vercel as is. Co-Authored-By: Claude Opus 5.5 --- apps/web/.gitignore | 4 + apps/web/Dockerfile | 37 + apps/web/Dockerfile.dockerignore | 7 + apps/web/README.md | 52 ++ apps/web/app/account/actions.ts | 60 ++ apps/web/app/account/challenge-card.tsx | 58 ++ apps/web/app/account/link-card.tsx | 57 ++ apps/web/app/account/link-settings.tsx | 35 + apps/web/app/account/page.tsx | 117 +++ apps/web/app/console/actions.ts | 45 + apps/web/app/console/console-nav.tsx | 22 + apps/web/app/console/page.tsx | 96 ++ apps/web/app/console/people/[id]/page.tsx | 184 ++++ apps/web/app/console/people/page.tsx | 68 ++ apps/web/app/device/actions.ts | 10 + apps/web/app/device/device-form.tsx | 42 + apps/web/app/device/page.tsx | 20 + apps/web/app/error.tsx | 17 + apps/web/app/globals.css | 131 +++ apps/web/app/icon.svg | 8 + apps/web/app/layout.tsx | 29 + apps/web/app/not-found.tsx | 13 + apps/web/app/online/page.tsx | 21 + apps/web/app/page.tsx | 72 ++ apps/web/app/signin/page.tsx | 18 + apps/web/app/signin/signin-form.tsx | 61 ++ apps/web/app/signup/page.tsx | 16 + apps/web/app/signup/signup-form.tsx | 85 ++ apps/web/components.json | 25 + apps/web/components/copy-button.tsx | 23 + apps/web/components/forms.tsx | 87 ++ apps/web/components/header-client.tsx | 52 ++ apps/web/components/header.tsx | 51 + apps/web/components/narrow-card.tsx | 22 + apps/web/components/online-list.tsx | 68 ++ apps/web/components/status.tsx | 82 ++ apps/web/components/time.tsx | 75 ++ apps/web/components/ui/alert.tsx | 75 ++ apps/web/components/ui/button.tsx | 57 ++ apps/web/components/ui/card.tsx | 102 ++ apps/web/components/ui/field.tsx | 238 +++++ apps/web/components/ui/input.tsx | 19 + apps/web/components/ui/label.tsx | 19 + apps/web/components/ui/native-select.tsx | 60 ++ apps/web/components/ui/separator.tsx | 24 + apps/web/components/ui/switch.tsx | 31 + apps/web/components/ui/table.tsx | 115 +++ apps/web/lib/actions.ts | 22 + apps/web/lib/api.ts | 78 ++ apps/web/lib/auth-client.ts | 10 + apps/web/lib/paths.ts | 6 + apps/web/lib/utils.ts | 1 + apps/web/next.config.ts | 25 + apps/web/package.json | 35 + apps/web/postcss.config.mjs | 1 + apps/web/tsconfig.json | 21 + bun.lock | 1025 +++++++++++++++++++++ 57 files changed, 3834 insertions(+) create mode 100644 apps/web/.gitignore create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/Dockerfile.dockerignore create mode 100644 apps/web/README.md create mode 100644 apps/web/app/account/actions.ts create mode 100644 apps/web/app/account/challenge-card.tsx create mode 100644 apps/web/app/account/link-card.tsx create mode 100644 apps/web/app/account/link-settings.tsx create mode 100644 apps/web/app/account/page.tsx create mode 100644 apps/web/app/console/actions.ts create mode 100644 apps/web/app/console/console-nav.tsx create mode 100644 apps/web/app/console/page.tsx create mode 100644 apps/web/app/console/people/[id]/page.tsx create mode 100644 apps/web/app/console/people/page.tsx create mode 100644 apps/web/app/device/actions.ts create mode 100644 apps/web/app/device/device-form.tsx create mode 100644 apps/web/app/device/page.tsx create mode 100644 apps/web/app/error.tsx create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/icon.svg create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/not-found.tsx create mode 100644 apps/web/app/online/page.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/app/signin/page.tsx create mode 100644 apps/web/app/signin/signin-form.tsx create mode 100644 apps/web/app/signup/page.tsx create mode 100644 apps/web/app/signup/signup-form.tsx create mode 100644 apps/web/components.json create mode 100644 apps/web/components/copy-button.tsx create mode 100644 apps/web/components/forms.tsx create mode 100644 apps/web/components/header-client.tsx create mode 100644 apps/web/components/header.tsx create mode 100644 apps/web/components/narrow-card.tsx create mode 100644 apps/web/components/online-list.tsx create mode 100644 apps/web/components/status.tsx create mode 100644 apps/web/components/time.tsx create mode 100644 apps/web/components/ui/alert.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/field.tsx create mode 100644 apps/web/components/ui/input.tsx create mode 100644 apps/web/components/ui/label.tsx create mode 100644 apps/web/components/ui/native-select.tsx create mode 100644 apps/web/components/ui/separator.tsx create mode 100644 apps/web/components/ui/switch.tsx create mode 100644 apps/web/components/ui/table.tsx create mode 100644 apps/web/lib/actions.ts create mode 100644 apps/web/lib/api.ts create mode 100644 apps/web/lib/auth-client.ts create mode 100644 apps/web/lib/paths.ts create mode 100644 apps/web/lib/utils.ts create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/tsconfig.json create mode 100644 bun.lock diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..da1770f --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,4 @@ +/.next/ +/node_modules/ +next-env.d.ts +*.tsbuildinfo diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..df19d23 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,37 @@ +# The hub's website as a standalone Next.js server. Build from the repository root (the app uses the workspace's +# packages for the API's types): +# +# docker build -f apps/web/Dockerfile --build-arg API_URL=http://api:4101 -t t5de-hub-web . +# docker run -p 4100:4100 -e API_URL=http://api:4101 t5de-hub-web +# +# API_URL is needed at build time too: Next fixes the /api rewrite when it builds. + +FROM oven/bun:1.4.4 AS bun + +FROM node:26-slim AS build +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun +WORKDIR /repo +ENV NEXT_TELEMETRY_DISABLED=1 +# Dependencies first, so they're cached while only source changes. +COPY package.json bun.lock ./ +COPY apps/web/package.json apps/web/ +COPY apps/api/package.json apps/api/ +COPY packages/db/package.json packages/db/ +RUN bun install --frozen-lockfile +COPY packages/db packages/db +COPY apps/api apps/api +COPY apps/web apps/web +ARG API_URL=http://api:4101 +ENV API_URL=$API_URL +RUN cd apps/web && bun run build + +FROM node:26-slim +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4100 HOSTNAME=0.0.0.0 +ARG API_URL=http://api:4101 +ENV API_URL=$API_URL +COPY --from=build --chown=node:node /repo/apps/web/.next/standalone ./ +COPY --from=build --chown=node:node /repo/apps/web/.next/static ./apps/web/.next/static +USER node +EXPOSE 4100 +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/Dockerfile.dockerignore b/apps/web/Dockerfile.dockerignore new file mode 100644 index 0000000..e51d47a --- /dev/null +++ b/apps/web/Dockerfile.dockerignore @@ -0,0 +1,7 @@ +**/node_modules +**/.next +**/.turbo +**/data +**/*.tsbuildinfo +.git +dist diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..e125fa6 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,52 @@ +# T5DE Hub website + +The hub's website: accounts, linked IMVU accounts, who's online, signing T5DE in, and the moderation console. +Next.js (App Router), Tailwind CSS and shadcn/ui. Pages are rendered on the server, which calls the hub's API +(`apps/api`) with the visitor's cookie; forms use server actions. The browser only talks to this app: `/api/*` is +rewritten to the API, so the API's session cookies belong to the website's origin. + +## Develop + +From the repository root, `bun install`, then start the API (`cd apps/api && bun src/index.ts`) and the website: + +```sh +cd apps/web +bun run dev # http://localhost:4100 +bun run check-types +bun run build +``` + +The first account created is the hub's admin. + +## Configuration + +| Variable | Default | What | +| --------- | ----------------------- | -------------------------------------------------------------------------------------- | +| `API_URL` | `http://localhost:4101` | The API's address as this app reaches it. Needed at build time and at run time (below). | +| `PORT` | `4100` | The standalone server's port (`next start` and `bun run dev` use 4100). | + +The API's `WEB_URL` must be this website's public address (it's where Better Auth trusts requests from, and where +T5DE's sign-in code page is). + +`API_URL` is read in two places: by server code on every request, and by `next.config.ts`'s `/api` rewrite, which +Next fixes when it builds. So build with the same `API_URL` the app will run with. + +## Deploy + +### Container + +The `Dockerfile` builds a standalone server (`output: "standalone"`). Build it from the repository root: + +```sh +docker build -f apps/web/Dockerfile --build-arg API_URL=http://api:4101 -t t5de-hub-web . +docker run -p 4100:4100 -e API_URL=http://api:4101 t5de-hub-web +``` + +Next's own gzip is off (it held back the API's live event stream), so put a reverse proxy that compresses in front, +and don't let it buffer `text/event-stream` responses (`/api/v1/online/events`). + +### Vercel + +Import the repository with the project's root directory set to `apps/web` (Vercel installs the Bun workspace from the +root). Set `API_URL` to the API's public address for both builds and runtime. Nothing else is needed: there's no +custom server. diff --git a/apps/web/app/account/actions.ts b/apps/web/app/account/actions.ts new file mode 100644 index 0000000..7283def --- /dev/null +++ b/apps/web/app/account/actions.ts @@ -0,0 +1,60 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { api, unwrap } from "@/lib/api"; +import { attempt, field, type ActionState } from "@/lib/actions"; + +const done = () => revalidatePath("/account"); + +/** Starts linking an IMVU account: the API makes a code to put in its tagline. */ +export async function linkAccount(_: ActionState, form: FormData): Promise { + return attempt(async () => { + unwrap(await api.account.links.post({ username: field(form, "username") })); + done(); + }); +} + +/** A pending link: check the tagline for the code, or cancel. */ +export async function challengeAction(_: ActionState, form: FormData): Promise { + const imvu = field(form, "imvu"); + return attempt(async () => { + if (field(form, "intent") === "cancel") { + unwrap(await api.account.challenges({ imvu }).delete()); + } else { + unwrap(await api.account.links({ imvu }).check.post()); + } + done(); + }); +} + +/** A linked account: check its age status with IMVU again, or unlink it. */ +export async function linkAction(_: ActionState, form: FormData): Promise { + const imvu = field(form, "imvu"); + return attempt(async () => { + if (field(form, "intent") === "unlink") { + unwrap(await api.account.links({ imvu }).delete()); + done(); + return; + } + unwrap(await api.account.links({ imvu }).recheck.post()); + done(); + return "Checked with IMVU just now."; + }); +} + +export async function saveSettings( + imvu: number, + settings: { showBadge?: boolean; showPresence?: boolean }, +): Promise { + return attempt(async () => { + unwrap(await api.account.links({ imvu }).patch(settings)); + done(); + }); +} + +export async function signOutComputers(_: ActionState, _form: FormData): Promise { + return attempt(async () => { + unwrap(await api.account["signout-computers"].post()); + done(); + return "Signed out everywhere."; + }); +} diff --git a/apps/web/app/account/challenge-card.tsx b/apps/web/app/account/challenge-card.tsx new file mode 100644 index 0000000..9345f7a --- /dev/null +++ b/apps/web/app/account/challenge-card.tsx @@ -0,0 +1,58 @@ +import { CopyButton } from "@/components/copy-button"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Challenge } from "@/lib/api"; +import { challengeAction } from "./actions"; + +/** A link in progress: the code to put in the tagline, then Check. */ +export function ChallengeCard({ challenge: c }: { challenge: Challenge }) { + return ( + + + Link {c.username} + To prove {c.username} is yours: + + + +
    + +

    + Put this code anywhere in {c.username}'s IMVU tagline (on your avatar card in the client, or your + profile): +

    +
    + + {c.code} + + +
    +
    + +

    Press Check. You can take the code out again once it's linked.

    +
    +
+
+ + Check + + + Cancel + + The code works for a day. +
+
+
+
+ ); +} + +function Step({ n, children }: { n: number; children: React.ReactNode }) { + return ( +
  • + + {n} + +
    {children}
    +
  • + ); +} diff --git a/apps/web/app/account/link-card.tsx b/apps/web/app/account/link-card.tsx new file mode 100644 index 0000000..a8632c0 --- /dev/null +++ b/apps/web/app/account/link-card.tsx @@ -0,0 +1,57 @@ +import { ActionForm, SubmitButton } from "@/components/forms"; +import { AgeTag, Initial, Outfit, Tag } from "@/components/status"; +import { TimeAgo } from "@/components/time"; +import type { Link } from "@/lib/api"; +import { linkAction } from "./actions"; +import { LinkSettings } from "./link-settings"; + +/** One linked IMVU account. */ +export function LinkCard({ link }: { link: Link }) { + return ( +
  • + +
    +
    +
    +
    {link.username}
    +
    + {link.online && ( + + Online in T5DE + + )} + + + checked + +
    +
    + + +
    + +
    + + Check again + + + Unlink + +
    +
    +
    +
  • + ); +} diff --git a/apps/web/app/account/link-settings.tsx b/apps/web/app/account/link-settings.tsx new file mode 100644 index 0000000..e01cd08 --- /dev/null +++ b/apps/web/app/account/link-settings.tsx @@ -0,0 +1,35 @@ +"use client"; +// A linked account's two settings, saved as soon as they're switched. +import { useOptimistic, useState, useTransition } from "react"; +import { FormMessage } from "@/components/forms"; +import { Switch } from "@/components/ui/switch"; +import type { ActionState } from "@/lib/actions"; +import { saveSettings } from "./actions"; + +type Settings = { showBadge: boolean; showPresence: boolean }; + +export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) { + const [settings, setOptimistic] = useOptimistic(saved); + const [state, setState] = useState(null); + const [pending, startTransition] = useTransition(); + + const change = (key: keyof Settings) => (value: boolean) => + startTransition(async () => { + setOptimistic({ ...settings, [key]: value }); + setState(await saveSettings(imvuId, { [key]: value })); + }); + + return ( +
    + + + +
    + ); +} diff --git a/apps/web/app/account/page.tsx b/apps/web/app/account/page.tsx new file mode 100644 index 0000000..2511836 --- /dev/null +++ b/apps/web/app/account/page.tsx @@ -0,0 +1,117 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { PageHeader, Tag } from "@/components/status"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { api, getMe, isModerator, unwrap } from "@/lib/api"; +import { linkAccount, signOutComputers } from "./actions"; +import { ChallengeCard } from "./challenge-card"; +import { LinkCard } from "./link-card"; + +export const metadata: Metadata = { title: "Account" }; + +export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) { + const me = await getMe(); + if (!me) redirect("/signin?next=/account"); + const account = unwrap(await api.account.get()); + const { person, links, challenges, computers } = account; + const welcome = (await searchParams).welcome !== undefined; + + return ( +
    + + + Signed in as {person.username} + {isModerator(person) && {person.role}} + + + + {welcome && ( + + Welcome to T5DE, {person.username}. + Link an IMVU account below to get started. + + )} + + + + IMVU accounts + + {links.length} of {person.linkCap} + + + + {links.length > 0 ? ( +
      + {links.map((link) => ( + + ))} +
    + ) : ( +

    No IMVU accounts linked yet.

    + )} +
    +
    + + {challenges.map((c) => ( + + ))} + + + + Link an IMVU account + + + {account.canLink ? ( + +
    + + Next +
    +
    + ) : ( +

    + You've linked as many IMVU accounts as you can ({person.linkCap}). Ask a moderator if you need more. +

    + )} +

    + Other people can't see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass) + can see adult community products; each account is checked on its own. +

    +
    +
    + + + + T5DE on your computers + + +

    + {computers > 0 + ? `Signed in on ${computers} computer${computers === 1 ? "" : "s"}.` + : "Not signed in on any computer yet."}{" "} + To sign T5DE in, it shows a code to enter at{" "} + + /device + + . +

    + {computers > 0 && ( + + + Sign out everywhere + + + )} +
    +
    +
    + ); +} diff --git a/apps/web/app/console/actions.ts b/apps/web/app/console/actions.ts new file mode 100644 index 0000000..945ca1b --- /dev/null +++ b/apps/web/app/console/actions.ts @@ -0,0 +1,45 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { api, unwrap } from "@/lib/api"; +import { attempt, field, type ActionState } from "@/lib/actions"; + +const roles = ["user", "moderator", "admin"] as const; + +/** Runs a change to a person from the console, then shows their page again. */ +async function onPerson(form: FormData, change: (id: string) => Promise): Promise { + const id = field(form, "id"); + return attempt(async () => { + const notice = await change(id); + revalidatePath(`/console/people/${id}`); + return notice; + }); +} + +export async function setLinkCap(_: ActionState, form: FormData): Promise { + return onPerson(form, async (id) => { + const linkCap = Number(field(form, "linkCap")); + const { person } = unwrap(await api.console.people({ id })["link-cap"].post({ linkCap })); + return `Link cap set to ${person.linkCap}.`; + }); +} + +export async function ban(_: ActionState, form: FormData): Promise { + return onPerson(form, async (id) => { + unwrap(await api.console.people({ id }).ban.post({ reason: field(form, "reason") })); + }); +} + +export async function unban(_: ActionState, form: FormData): Promise { + return onPerson(form, async (id) => { + unwrap(await api.console.people({ id }).unban.post()); + }); +} + +export async function setRole(_: ActionState, form: FormData): Promise { + return onPerson(form, async (id) => { + const role = roles.find((r) => r === field(form, "role")) ?? "user"; + // The API's schema builds its union from an array, which its type reads as never; the value is checked above. + const { person } = unwrap(await api.console.people({ id }).role.post({ role: role as never })); + return `Role set to ${person.role}.`; + }); +} diff --git a/apps/web/app/console/console-nav.tsx b/apps/web/app/console/console-nav.tsx new file mode 100644 index 0000000..41ec5a8 --- /dev/null +++ b/apps/web/app/console/console-nav.tsx @@ -0,0 +1,22 @@ +import { notFound, redirect } from "next/navigation"; +import { NavLink } from "@/components/header-client"; +import { getMe, isModerator, type Person } from "@/lib/api"; + +/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */ +export async function requireModerator(path: string): Promise { + const me = await getMe(); + if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`); + if (!isModerator(me)) notFound(); + return me; +} + +export function ConsoleNav() { + return ( + + ); +} diff --git a/apps/web/app/console/page.tsx b/apps/web/app/console/page.tsx new file mode 100644 index 0000000..30a89b9 --- /dev/null +++ b/apps/web/app/console/page.tsx @@ -0,0 +1,96 @@ +import type { Metadata } from "next"; +import { OnlineList } from "@/components/online-list"; +import { PageHeader } from "@/components/status"; +import { DateTime } from "@/components/time"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { api, unwrap, type AuditEntry } from "@/lib/api"; +import { ConsoleNav, requireModerator } from "./console-nav"; + +export const metadata: Metadata = { title: "Console" }; + +export default async function Console() { + await requireModerator("/console"); + const data = unwrap(await api.console.get()); + const stats = [ + { label: "People", value: data.counts.people, note: "accounts" }, + { label: "IMVU accounts", value: data.counts.links, note: "linked" }, + { label: "Computers", value: data.counts.computers, note: "signed in" }, + ]; + + return ( + <> + Moderation. + +
    +
    + {stats.map((s) => ( + + + {s.label} + {s.value} + + {s.note} + + ))} +
    + + + + Online now + + {data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded + + + + + + + + + + Audit log + The latest 50 changes. + + + + + +
    + + ); +} + +function AuditLog({ entries }: { entries: AuditEntry[] }) { + if (entries.length === 0) return

    Nothing yet.

    ; + return ( + + + + When + Who + What + Target + Detail + + + + {entries.map((e, i) => ( + + + + + {e.actor || hub} + {e.action} + {e.target} + + {Object.entries((e.detail ?? {}) as Record) + .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`) + .join(", ")} + + + ))} + +
    + ); +} diff --git a/apps/web/app/console/people/[id]/page.tsx b/apps/web/app/console/people/[id]/page.tsx new file mode 100644 index 0000000..a73e868 --- /dev/null +++ b/apps/web/app/console/people/[id]/page.tsx @@ -0,0 +1,184 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { AgeTag, PageHeader, Tag } from "@/components/status"; +import { DateTime } from "@/components/time"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { api, ApiError, unwrap } from "@/lib/api"; +import { ban, setLinkCap, setRole, unban } from "../../actions"; +import { ConsoleNav, requireModerator } from "../../console-nav"; + +export const metadata: Metadata = { title: "Person" }; + +export default async function PersonPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const me = await requireModerator(`/console/people/${id}`); + let data; + try { + data = unwrap(await api.console.people({ id }).get()); + } catch (e) { + if (e instanceof ApiError && e.status === 404) notFound(); + throw e; + } + const { person, links, computers } = data; + + return ( + <> + + + {person.role} + · + + joined + + {person.banned && banned: {person.banReason}} + + + + +
    + + + IMVU accounts + + {links.length} of {person.linkCap}. Only moderators see which IMVU accounts belong to one person. + + + + {links.length === 0 ? ( +

    None.

    + ) : ( + + + + IMVU account + ID + Linked + Age + Shows + + + + {links.map((l) => ( + + + + {l.username} + {l.online && ( + + online + + )} + + + {l.imvuId} + + + + + + + + {[l.showBadge && "badge", l.showPresence && "presence"].filter(Boolean).join(", ") || "nothing"} + + + ))} + +
    + )} +

    + Signed in on {computers} computer{computers === 1 ? "" : "s"}. +

    +
    +
    + +
    + + + Link cap + How many IMVU accounts they can link (normally 10). + + + +
    + + Set +
    +
    +
    +
    + + + + Ban + + {person.banned + ? `Banned: ${person.banReason}` + : "Covers all their IMVU accounts, and signs them out everywhere."} + + + + {person.banned ? ( + + + Unban + + + ) : ( + + + + Ban + + + )} + + + + {me.role === "admin" && ( + + + Role + Moderators use the console; admins also set roles. + + + +
    + + User + Moderator + Admin + + Set +
    +
    +
    +
    + )} +
    +
    + + ); +} diff --git a/apps/web/app/console/people/page.tsx b/apps/web/app/console/people/page.tsx new file mode 100644 index 0000000..136bd88 --- /dev/null +++ b/apps/web/app/console/people/page.tsx @@ -0,0 +1,68 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { PageHeader, Tag } from "@/components/status"; +import { DateTime } from "@/components/time"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { api, unwrap } from "@/lib/api"; +import { ConsoleNav, requireModerator } from "../console-nav"; + +export const metadata: Metadata = { title: "People" }; + +export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) { + const raw = (await searchParams).q; + const q = typeof raw === "string" ? raw.slice(0, 64) : ""; + await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people"); + const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} })); + + return ( + <> + + + + +
    + + +
    + {people.length === 0 ? ( +

    Nobody found.

    + ) : ( + + + + Username + Role + Link cap + Joined + + + + + {people.map((p) => ( + + + + {p.username} + + + {p.role} + {p.linkCap} + + + + {p.banned && banned} + + ))} + +
    + )} +
    +
    + + ); +} diff --git a/apps/web/app/device/actions.ts b/apps/web/app/device/actions.ts new file mode 100644 index 0000000..8e06fec --- /dev/null +++ b/apps/web/app/device/actions.ts @@ -0,0 +1,10 @@ +"use server"; +import { api, unwrap } from "@/lib/api"; +import { attempt, field, type ActionState } from "@/lib/actions"; + +/** Approves the sign-in code T5DE shows, signing that computer in as this person. */ +export async function approveDevice(_: ActionState, form: FormData): Promise { + return attempt(async () => { + unwrap(await api.device.approve.post({ code: field(form, "code") })); + }); +} diff --git a/apps/web/app/device/device-form.tsx b/apps/web/app/device/device-form.tsx new file mode 100644 index 0000000..32aaca1 --- /dev/null +++ b/apps/web/app/device/device-form.tsx @@ -0,0 +1,42 @@ +"use client"; +import { useActionState } from "react"; +import { FormMessage, SubmitButton } from "@/components/forms"; +import { NarrowCard } from "@/components/narrow-card"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { approveDevice } from "./actions"; + +export function DeviceForm({ code, username }: { code: string; username: string }) { + const [state, action] = useActionState(approveDevice, null); + + if (state && !state.error) + return ; + + return ( + +
    + + + Code + + + + + Sign in + Only enter a code you got from T5DE yourself. + + +
    +
    + ); +} diff --git a/apps/web/app/device/page.tsx b/apps/web/app/device/page.tsx new file mode 100644 index 0000000..64bdf9d --- /dev/null +++ b/apps/web/app/device/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getMe } from "@/lib/api"; +import { DeviceForm } from "./device-form"; + +export const metadata: Metadata = { title: "Sign T5DE in" }; + +type Search = { user_code?: string | string[]; code?: string | string[] }; + +export default async function Device({ searchParams }: { searchParams: Promise }) { + const search = await searchParams; + // Better Auth's verification link carries user_code; ?code= is shorter to type. + const code = [search.user_code, search.code].find((c) => typeof c === "string") ?? ""; + const me = await getMe(); + if (!me) { + const here = code ? `/device?code=${encodeURIComponent(code)}` : "/device"; + redirect(`/signin?next=${encodeURIComponent(here)}`); + } + return ; +} diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000..416a5f1 --- /dev/null +++ b/apps/web/app/error.tsx @@ -0,0 +1,17 @@ +"use client"; +import Link from "next/link"; +import { NarrowCard } from "@/components/narrow-card"; +import { Button, buttonVariants } from "@/components/ui/button"; + +export default function ErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( + +
    + + + Home + +
    +
    + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..4487f81 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,131 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (@media (prefers-color-scheme: dark)); + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/apps/web/app/icon.svg b/apps/web/app/icon.svg new file mode 100644 index 0000000..90ca9c0 --- /dev/null +++ b/apps/web/app/icon.svg @@ -0,0 +1,8 @@ + + + + T5 + diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..7e7cd69 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { Header } from "@/components/header"; +import { cn } from "@/lib/utils"; +import "./globals.css"; + +const sans = Geist({ subsets: ["latin"], variable: "--font-sans" }); +const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" }); + +export const metadata: Metadata = { + title: { template: "%s · T5DE Hub", default: "T5DE Hub" }, + description: "One account for all your IMVU avatars.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
    +
    {children}
    +
    +
    + T5DE is unofficial: not made by or affiliated with IMVU. +
    +
    + + + ); +} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx new file mode 100644 index 0000000..a7ac12b --- /dev/null +++ b/apps/web/app/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +import { NarrowCard } from "@/components/narrow-card"; +import { buttonVariants } from "@/components/ui/button"; + +export default function NotFound() { + return ( + + + Home + + + ); +} diff --git a/apps/web/app/online/page.tsx b/apps/web/app/online/page.tsx new file mode 100644 index 0000000..d3cdd3a --- /dev/null +++ b/apps/web/app/online/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { OnlineList } from "@/components/online-list"; +import { PageHeader } from "@/components/status"; +import { Card, CardContent } from "@/components/ui/card"; +import { api, getMe, isModerator, unwrap } from "@/lib/api"; + +export const metadata: Metadata = { title: "Online" }; + +export default async function Online() { + const [me, { online }] = await Promise.all([getMe(), api.online.get().then(unwrap)]); + return ( + <> + IMVU accounts in a T5DE client right now, that chose to show it. + + + + + + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..1446274 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import { api, getMe, unwrap } from "@/lib/api"; +import { buttonVariants } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default async function Home() { + const [me, home] = await Promise.all([getMe(), api.home.get().then(unwrap)]); + const links = "links" in home ? home.links : undefined; + + return ( +
    +
    +

    T5DE Hub

    +

    + One account for all your IMVU avatars. +

    +

    + Link your IMVU accounts, see who's online in T5DE, and soon, community products. +

    + {!me && ( +
    + + Create account + + + Sign in + +
    + )} +
    + +
    + + + Online in T5DE + {home.onlineCount} + + + people showing that they're online.{" "} + + See who + + + + {links !== undefined ? ( + + + Your IMVU accounts + {links} + + + linked.{" "} + + Manage + + + + ) : ( + + + One account, all your avatars + + + Link any of your IMVU accounts by putting a code in its tagline. Your accounts stay private: nobody else + can see they're yours. + + + )} +
    +
    + ); +} diff --git a/apps/web/app/signin/page.tsx b/apps/web/app/signin/page.tsx new file mode 100644 index 0000000..a5c2af8 --- /dev/null +++ b/apps/web/app/signin/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { NarrowCard } from "@/components/narrow-card"; +import { getMe } from "@/lib/api"; +import { safeNext } from "@/lib/paths"; +import { SignInForm } from "./signin-form"; + +export const metadata: Metadata = { title: "Sign in" }; + +export default async function SignIn({ searchParams }: { searchParams: Promise<{ next?: string | string[] }> }) { + const next = safeNext((await searchParams).next); + if (await getMe()) redirect(next); + return ( + + + + ); +} diff --git a/apps/web/app/signin/signin-form.tsx b/apps/web/app/signin/signin-form.tsx new file mode 100644 index 0000000..5d60b86 --- /dev/null +++ b/apps/web/app/signin/signin-form.tsx @@ -0,0 +1,61 @@ +"use client"; +import Link from "next/link"; +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 { authClient } from "@/lib/auth-client"; + +/** Signs in with Better Auth, then goes to next (already checked to be a path on this site). */ +export function SignInForm({ next }: { next: string }) { + const router = useRouter(); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submit(form: FormData) { + setError(""); + setPending(true); + const { error } = await authClient.signIn.username({ + username: String(form.get("username")), + password: String(form.get("password")), + }); + if (error) { + setError(error.message ?? "That didn't work. Try again."); + setPending(false); + return; + } + router.push(next); + router.refresh(); + } + + return ( +
    { + e.preventDefault(); + void submit(new FormData(e.currentTarget)); + }} + > + + + Username + + + + Password + + + {error && {error}} + + + + No account? Create one + + + +
    + ); +} diff --git a/apps/web/app/signup/page.tsx b/apps/web/app/signup/page.tsx new file mode 100644 index 0000000..1b53bec --- /dev/null +++ b/apps/web/app/signup/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { NarrowCard } from "@/components/narrow-card"; +import { getMe } from "@/lib/api"; +import { SignUpForm } from "./signup-form"; + +export const metadata: Metadata = { title: "Create account" }; + +export default async function SignUp() { + if (await getMe()) redirect("/account"); + return ( + + + + ); +} diff --git a/apps/web/app/signup/signup-form.tsx b/apps/web/app/signup/signup-form.tsx new file mode 100644 index 0000000..63edc7a --- /dev/null +++ b/apps/web/app/signup/signup-form.tsx @@ -0,0 +1,85 @@ +"use client"; +import Link from "next/link"; +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 { authClient } from "@/lib/auth-client"; + +export function SignUpForm() { + const router = useRouter(); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submit(form: FormData) { + const username = String(form.get("username")); + const password = String(form.get("password")); + if (password !== form.get("password2")) { + setError("The passwords don't match."); + return; + } + setError(""); + setPending(true); + const { error } = await authClient.signUp.email({ + username, + name: username, + email: String(form.get("email")), + password, + }); + if (error) { + setError(error.message ?? "That didn't work. Try again."); + setPending(false); + return; + } + router.push("/account?welcome=1"); + router.refresh(); + } + + return ( +
    { + e.preventDefault(); + void submit(new FormData(e.currentTarget)); + }} + > + + + Username + + + + Email + + + + Password + + + + Password again + + Not your IMVU password: use a new one. + + {error && {error}} + + + + Have one? Sign in + + + +
    + ); +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/web/components/copy-button.tsx b/apps/web/components/copy-button.tsx new file mode 100644 index 0000000..d968a9e --- /dev/null +++ b/apps/web/components/copy-button.tsx @@ -0,0 +1,23 @@ +"use client"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} diff --git a/apps/web/components/forms.tsx b/apps/web/components/forms.tsx new file mode 100644 index 0000000..2dd9a3d --- /dev/null +++ b/apps/web/components/forms.tsx @@ -0,0 +1,87 @@ +"use client"; +// Forms that run a server action and show its answer, with buttons that show when they're working. +import { useActionState } from "react"; +import { useFormStatus } from "react-dom"; +import { Button } from "@/components/ui/button"; +import type { ActionState } from "@/lib/actions"; +import { cn } from "@/lib/utils"; + +type Action = (state: ActionState, form: FormData) => Promise; + +export function ActionForm({ + action, + fields = {}, + className, + children, +}: { + action: Action; + /** Hidden fields sent with the form. */ + fields?: Record; + className?: string; + children: React.ReactNode; +}) { + const [state, formAction] = useActionState(action, null); + return ( +
    + {Object.entries(fields).map(([name, value]) => ( + + ))} + {children} + + + ); +} + +export function FormMessage({ state }: { state: ActionState }) { + if (state?.error) return {state.error}; + if (state?.notice) + return ( +

    + {state.notice} +

    + ); + return null; +} + +export function ErrorText({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ); +} + +/** + * A submit button that shows pending while its form is being sent. With name and value, it's one of several + * buttons in the form, and only shows pending when it was the one pressed. With confirm, it asks first. + */ +export function SubmitButton({ + name, + value, + pendingLabel, + confirm, + children, + ...props +}: Omit, "type" | "name" | "value" | "onClick"> & { + name?: string; + value?: string; + pendingLabel?: string; + confirm?: string; +}) { + const status = useFormStatus(); + const mine = status.pending && (name === undefined || status.data?.get(name) === value); + return ( + + ); +} diff --git a/apps/web/components/header-client.tsx b/apps/web/components/header-client.tsx new file mode 100644 index 0000000..367204c --- /dev/null +++ b/apps/web/components/header-client.tsx @@ -0,0 +1,52 @@ +"use client"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; +import { cn } from "@/lib/utils"; + +/** A link in a navigation bar, marked when it's the current page (or, unless exact, a page under it). */ +export function NavLink({ + href, + exact = false, + children, +}: { + href: string; + exact?: boolean; + children: React.ReactNode; +}) { + const pathname = usePathname(); + const here = pathname === href || (!exact && pathname.startsWith(href + "/")); + return ( + + {children} + + ); +} + +export function SignOutButton() { + const router = useRouter(); + const [pending, setPending] = useState(false); + return ( + + ); +} diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx new file mode 100644 index 0000000..2c9cd2a --- /dev/null +++ b/apps/web/components/header.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import { getMe, isModerator } from "@/lib/api"; +import { buttonVariants } from "@/components/ui/button"; +import { NavLink, SignOutButton } from "./header-client"; + +export function Logo() { + return ( + + T5 + + ); +} + +export async function Header() { + const me = await getMe(); + return ( +
    +
    + + + T5DE Hub + + +
    + {me ? ( + <> + {me.username} + + + ) : ( + <> + + Sign in + + + Create account + + + )} +
    +
    +
    + ); +} diff --git a/apps/web/components/narrow-card.tsx b/apps/web/components/narrow-card.tsx new file mode 100644 index 0000000..1cf485d --- /dev/null +++ b/apps/web/components/narrow-card.tsx @@ -0,0 +1,22 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +/** A small centered card: sign-in, sign-up, device sign-in, errors. */ +export function NarrowCard({ + title, + description, + children, +}: { + title: React.ReactNode; + description?: React.ReactNode; + children?: React.ReactNode; +}) { + return ( + + + {title} + {description && {description}} + + {children && {children}} + + ); +} diff --git a/apps/web/components/online-list.tsx b/apps/web/components/online-list.tsx new file mode 100644 index 0000000..d2f4451 --- /dev/null +++ b/apps/web/components/online-list.tsx @@ -0,0 +1,68 @@ +"use client"; +// Who's online in T5DE, kept live: the page renders the list, then this follows the API's event stream. +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Initial, Outfit, Tag } from "@/components/status"; +import { Since } from "@/components/time"; +import type { OnlineEntry } from "@/lib/api"; + +export function OnlineList({ initial, moderator = false }: { initial: OnlineEntry[]; moderator?: boolean }) { + const [online, setOnline] = useState(initial); + const [live, setLive] = useState(false); + + useEffect(() => { + const events = new EventSource("/api/v1/online/events"); + events.addEventListener("online", (e) => { + setOnline((JSON.parse(e.data) as { online: OnlineEntry[] }).online); + setLive(true); + }); + events.addEventListener("error", () => setLive(false)); + return () => events.close(); + }, []); + + return ( +
    +
    + + {live ? "Live" : "Connecting…"} + + {online.length} {online.length === 1 ? "account" : "accounts"} + +
    + {online.length === 0 ? ( +

    Nobody is showing that they're online right now.

    + ) : ( +
      + {online.map((o) => ( +
    • + +
      +
      + {o.username} + {o.badge && T5DE} + + online since + + {o.hidden && hidden: moderators only} + {moderator && o.adult && adult} + {moderator && o.personId && ( + + person + + )} +
      + +
      +
    • + ))} +
    + )} +
    + ); +} diff --git a/apps/web/components/status.tsx b/apps/web/components/status.tsx new file mode 100644 index 0000000..374c5b2 --- /dev/null +++ b/apps/web/components/status.tsx @@ -0,0 +1,82 @@ +// Small labels. Color only means something: green is online, blue is age verified or adult, red is banned. +import type { Link } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +const tones = { + neutral: "border-border bg-muted/60 text-muted-foreground", + strong: "border-foreground/15 bg-foreground/5 text-foreground", + online: "border-emerald-600/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", + adult: "border-sky-600/20 bg-sky-500/10 text-sky-700 dark:text-sky-400", + danger: "border-red-600/20 bg-red-500/10 text-red-700 dark:text-red-400", +}; + +export function Tag({ + tone = "neutral", + dot = false, + className, + children, +}: { + tone?: keyof typeof tones; + dot?: boolean; + className?: string; + children: React.ReactNode; +}) { + return ( + + {dot && } + {children} + + ); +} + +/** A round initial standing in for an avatar picture. */ +export function Initial({ name }: { name: string }) { + return ( + + {name.slice(0, 1)} + + ); +} + +/** The community products an account wears. */ +export function Outfit({ items }: { items: { id: string; name: string }[] }) { + if (items.length === 0) return null; + return ( +
    +
    Wearing
    +
      + {items.map((item) => ( +
    • + {item.name} +
    • + ))} +
    +
    + ); +} + +/** A page's title and the line under it. */ +export function PageHeader({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) { + return ( +
    +

    {title}

    + {children &&
    {children}
    } +
    + ); +} + +/** An IMVU account's age status: blue when it can see adult community products. */ +export function AgeTag({ link }: { link: Pick }) { + if (link.ageVerified) return Age verified; + if (link.accessPass) return Access Pass; + return Not age verified; +} diff --git a/apps/web/components/time.tsx b/apps/web/components/time.tsx new file mode 100644 index 0000000..e542948 --- /dev/null +++ b/apps/web/components/time.tsx @@ -0,0 +1,75 @@ +"use client"; +// Times in the visitor's own time zone and language. The server doesn't know either, so it renders times in UTC and +// the browser replaces them once the page is running. +import { useEffect, useState, useSyncExternalStore } from "react"; + +const noSubscription = () => () => {}; + +/** False on the server and while hydrating, true after. */ +function useInBrowser(): boolean { + return useSyncExternalStore( + noSubscription, + () => true, + () => false, + ); +} + +const units: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 365 * 24 * 3600], + ["month", 30 * 24 * 3600], + ["week", 7 * 24 * 3600], + ["day", 24 * 3600], + ["hour", 3600], + ["minute", 60], +]; +const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + +function ago(iso: string, now: number): string { + const seconds = (new Date(iso).getTime() - now) / 1000; + for (const [unit, size] of units) { + if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit); + } + return "just now"; +} + +/** "5 minutes ago", kept up to date. */ +export function TimeAgo({ iso }: { iso: string }) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + const local = useInBrowser(); + return ( + + ); +} + +function format(iso: string, options: Intl.DateTimeFormatOptions, local: boolean): string { + return new Date(iso).toLocaleString(local ? undefined : "en", local ? options : { ...options, timeZone: "UTC" }); +} + +/** A date (and time). */ +export function DateTime({ iso, dateOnly = false }: { iso: string; dateOnly?: boolean }) { + const local = useInBrowser(); + const options: Intl.DateTimeFormatOptions = dateOnly + ? { dateStyle: "medium" } + : { dateStyle: "medium", timeStyle: "short" }; + return ; +} + +/** A time of day ("3:04 PM"), with the date too when it isn't today. */ +export function Since({ iso }: { iso: string }) { + const local = useInBrowser(); + const today = local && new Date(iso).toDateString() === new Date().toDateString(); + const options: Intl.DateTimeFormatOptions = today + ? { hour: "numeric", minute: "2-digit" } + : { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }; + return ( + + ); +} diff --git a/apps/web/components/ui/alert.tsx b/apps/web/components/ui/alert.tsx new file mode 100644 index 0000000..17ba728 --- /dev/null +++ b/apps/web/components/ui/alert.tsx @@ -0,0 +1,75 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
    + ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx new file mode 100644 index 0000000..997e0c7 --- /dev/null +++ b/apps/web/components/ui/button.tsx @@ -0,0 +1,57 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +const buttonVariants = 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", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/apps/web/components/ui/card.tsx b/apps/web/components/ui/card.tsx new file mode 100644 index 0000000..cf02f48 --- /dev/null +++ b/apps/web/components/ui/card.tsx @@ -0,0 +1,102 @@ +import * as React from "react" +import { cn } from "cn" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
    img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/apps/web/components/ui/field.tsx b/apps/web/components/ui/field.tsx new file mode 100644 index 0000000..8ae7f29 --- /dev/null +++ b/apps/web/components/ui/field.tsx @@ -0,0 +1,238 @@ +"use client" + +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
    [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
    + ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +