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 <[email protected]>
This commit is contained in:
1 parent
7afada1c63
commit
64c552330b
57 files changed
+3834
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
/.next/
|
||||||
|
/node_modules/
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.next
|
||||||
|
**/.turbo
|
||||||
|
**/data
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.git
|
||||||
|
dist
|
||||||
@@ -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.
|
||||||
@@ -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<ActionState> {
|
||||||
|
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<ActionState> {
|
||||||
|
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<ActionState> {
|
||||||
|
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<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.account.links({ imvu }).patch(settings));
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOutComputers(_: ActionState, _form: FormData): Promise<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.account["signout-computers"].post());
|
||||||
|
done();
|
||||||
|
return "Signed out everywhere.";
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card id={`link-${c.imvuId}`}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Link {c.username}</CardTitle>
|
||||||
|
<CardDescription>To prove {c.username} is yours:</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={challengeAction} fields={{ imvu: c.imvuId }} className="space-y-6">
|
||||||
|
<ol className="space-y-5 text-sm">
|
||||||
|
<Step n={1}>
|
||||||
|
<p>
|
||||||
|
Put this code anywhere in {c.username}'s IMVU tagline (on your avatar card in the client, or your
|
||||||
|
profile):
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<code className="rounded-lg border bg-muted/50 px-4 py-2.5 font-mono text-xl font-semibold tracking-wider select-all sm:text-2xl">
|
||||||
|
{c.code}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={c.code} />
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
<Step n={2}>
|
||||||
|
<p>Press Check. You can take the code out again once it's linked.</p>
|
||||||
|
</Step>
|
||||||
|
</ol>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<SubmitButton name="intent" value="check" pendingLabel="Checking…">
|
||||||
|
Check
|
||||||
|
</SubmitButton>
|
||||||
|
<SubmitButton variant="outline" name="intent" value="cancel" pendingLabel="Cancelling…">
|
||||||
|
Cancel
|
||||||
|
</SubmitButton>
|
||||||
|
<span className="text-sm text-muted-foreground">The code works for a day.</span>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({ n, children }: { n: number; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="grid size-6 shrink-0 place-items-center rounded-full border text-xs font-medium text-muted-foreground">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 pt-0.5">{children}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<li className="flex gap-4 py-6 first:pt-0 last:pb-0">
|
||||||
|
<Initial name={link.username} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-5 sm:flex-row sm:items-start">
|
||||||
|
<div className="min-w-0 flex-1 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">{link.username}</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{link.online && (
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
Online in T5DE
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<AgeTag link={link} />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
checked <TimeAgo iso={link.checkedAt} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Outfit items={link.outfit} />
|
||||||
|
<LinkSettings imvuId={link.imvuId} showBadge={link.showBadge} showPresence={link.showPresence} />
|
||||||
|
</div>
|
||||||
|
<ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right">
|
||||||
|
<div className="flex gap-2 sm:justify-end">
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
name="intent"
|
||||||
|
value="recheck"
|
||||||
|
pendingLabel="Checking…"
|
||||||
|
title="Check age verification with IMVU again"
|
||||||
|
>
|
||||||
|
Check again
|
||||||
|
</SubmitButton>
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
name="intent"
|
||||||
|
value="unlink"
|
||||||
|
pendingLabel="Unlinking…"
|
||||||
|
confirm={`Unlink ${link.username}? To link it again, you'll need a new code in its tagline.`}
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ActionState>(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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Switch checked={settings.showBadge} onCheckedChange={change("showBadge")} disabled={pending} />
|
||||||
|
Show T5DE badge
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} />
|
||||||
|
Show when I'm online in T5DE
|
||||||
|
</label>
|
||||||
|
<FormMessage state={state} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="Your account">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
Signed in as {person.username}
|
||||||
|
{isModerator(person) && <Tag tone="strong">{person.role}</Tag>}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{welcome && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Welcome to T5DE, {person.username}.</AlertTitle>
|
||||||
|
<AlertDescription>Link an IMVU account below to get started.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>IMVU accounts</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{links.length} of {person.linkCap}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{links.length > 0 ? (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{links.map((link) => (
|
||||||
|
<LinkCard key={link.imvuId} link={link} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No IMVU accounts linked yet.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{challenges.map((c) => (
|
||||||
|
<ChallengeCard key={c.imvuId} challenge={c} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Link an IMVU account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{account.canLink ? (
|
||||||
|
<ActionForm action={linkAccount}>
|
||||||
|
<div className="flex max-w-md gap-2">
|
||||||
|
<Input name="username" placeholder="IMVU username" required maxLength={64} aria-label="IMVU username" />
|
||||||
|
<SubmitButton pendingLabel="Looking…">Next</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">
|
||||||
|
You've linked as many IMVU accounts as you can ({person.linkCap}). Ask a moderator if you need more.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>T5DE on your computers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{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{" "}
|
||||||
|
<Link href="/device" className="text-foreground underline underline-offset-4">
|
||||||
|
/device
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
{computers > 0 && (
|
||||||
|
<ActionForm action={signOutComputers}>
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
pendingLabel="Signing out…"
|
||||||
|
confirm="Sign T5DE out on all your computers?"
|
||||||
|
>
|
||||||
|
Sign out everywhere
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | void>): Promise<ActionState> {
|
||||||
|
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<ActionState> {
|
||||||
|
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<ActionState> {
|
||||||
|
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<ActionState> {
|
||||||
|
return onPerson(form, async (id) => {
|
||||||
|
unwrap(await api.console.people({ id }).unban.post());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRole(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
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}.`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Person> {
|
||||||
|
const me = await getMe();
|
||||||
|
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
|
||||||
|
if (!isModerator(me)) notFound();
|
||||||
|
return me;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConsoleNav() {
|
||||||
|
return (
|
||||||
|
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
|
||||||
|
<NavLink href="/console" exact>
|
||||||
|
Overview
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/console/people">People</NavLink>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Console">Moderation.</PageHeader>
|
||||||
|
<ConsoleNav />
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<Card key={s.label}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{s.label}</CardDescription>
|
||||||
|
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">{s.note}</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Online now</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<OnlineList initial={data.online} moderator />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Audit log</CardTitle>
|
||||||
|
<CardDescription>The latest 50 changes.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AuditLog entries={data.audit} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditLog({ entries }: { entries: AuditEntry[] }) {
|
||||||
|
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>;
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Who</TableHead>
|
||||||
|
<TableHead>What</TableHead>
|
||||||
|
<TableHead>Target</TableHead>
|
||||||
|
<TableHead>Detail</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={e.at} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{e.action}</TableCell>
|
||||||
|
<TableCell>{e.target}</TableCell>
|
||||||
|
<TableCell className="text-xs whitespace-normal text-muted-foreground">
|
||||||
|
{Object.entries((e.detail ?? {}) as Record<string, unknown>)
|
||||||
|
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||||
|
.join(", ")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title={person.username}>
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="capitalize">{person.role}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>
|
||||||
|
joined <DateTime iso={person.createdAt} dateOnly />
|
||||||
|
</span>
|
||||||
|
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
<ConsoleNav />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>IMVU accounts</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{links.length} of {person.linkCap}. Only moderators see which IMVU accounts belong to one person.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{links.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">None.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>IMVU account</TableHead>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Linked</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead>Shows</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{links.map((l) => (
|
||||||
|
<TableRow key={l.imvuId}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{l.username}
|
||||||
|
{l.online && (
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
online
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground tabular-nums">{l.imvuId}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={l.linkedAt} dateOnly />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AgeTag link={l} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{[l.showBadge && "badge", l.showPresence && "presence"].filter(Boolean).join(", ") || "nothing"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Signed in on {computers} computer{computers === 1 ? "" : "s"}.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Link cap</CardTitle>
|
||||||
|
<CardDescription>How many IMVU accounts they can link (normally 10).</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={setLinkCap} fields={{ id: person.id }}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="linkCap"
|
||||||
|
defaultValue={person.linkCap}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
required
|
||||||
|
aria-label="Link cap"
|
||||||
|
className="w-28"
|
||||||
|
/>
|
||||||
|
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ban</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{person.banned
|
||||||
|
? `Banned: ${person.banReason}`
|
||||||
|
: "Covers all their IMVU accounts, and signs them out everywhere."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{person.banned ? (
|
||||||
|
<ActionForm action={unban} fields={{ id: person.id }}>
|
||||||
|
<SubmitButton variant="outline" pendingLabel="Unbanning…">
|
||||||
|
Unban
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
) : (
|
||||||
|
<ActionForm action={ban} fields={{ id: person.id }}>
|
||||||
|
<Input
|
||||||
|
name="reason"
|
||||||
|
placeholder="Reason (they see it)"
|
||||||
|
required
|
||||||
|
maxLength={500}
|
||||||
|
aria-label="Reason"
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
className="bg-red-600 text-white hover:bg-red-600/90 dark:bg-red-600 dark:hover:bg-red-600/90"
|
||||||
|
pendingLabel="Banning…"
|
||||||
|
confirm={`Ban ${person.username}?`}
|
||||||
|
>
|
||||||
|
Ban
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{me.role === "admin" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Role</CardTitle>
|
||||||
|
<CardDescription>Moderators use the console; admins also set roles.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={setRole} fields={{ id: person.id }}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<NativeSelect name="role" defaultValue={person.role} aria-label="Role">
|
||||||
|
<NativeSelectOption value="user">User</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="moderator">Moderator</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="admin">Admin</NativeSelectOption>
|
||||||
|
</NativeSelect>
|
||||||
|
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title="People" />
|
||||||
|
<ConsoleNav />
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<form action="/console/people" className="flex max-w-md gap-2">
|
||||||
|
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{people.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Nobody found.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Username</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Link cap</TableHead>
|
||||||
|
<TableHead>Joined</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{people.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link href={`/console/people/${p.id}`} className="font-medium underline-offset-4 hover:underline">
|
||||||
|
{p.username}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="capitalize">{p.role}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">{p.linkCap}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={p.createdAt} dateOnly />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{p.banned && <Tag tone="danger">banned</Tag>}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.device.approve.post({ code: field(form, "code") }));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 <NarrowCard title="T5DE is signed in" description="You can close this page and go back to T5DE." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Sign T5DE in" description={`Enter the code T5DE shows, to sign it in as ${username}.`}>
|
||||||
|
<form action={action}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="code">Code</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
name="code"
|
||||||
|
defaultValue={code}
|
||||||
|
placeholder="XXXX-XXXX"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-11 text-center font-mono text-lg tracking-widest uppercase"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<FormMessage state={state} />
|
||||||
|
<Field>
|
||||||
|
<SubmitButton pendingLabel="Signing in…">Sign in</SubmitButton>
|
||||||
|
<FieldDescription>Only enter a code you got from T5DE yourself.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Search> }) {
|
||||||
|
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 <DeviceForm code={code} username={me.username} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<NarrowCard title="Sorry" description="Something went wrong on our side. Try again in a moment.">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={reset}>Try again</Button>
|
||||||
|
<Link href="/" className={buttonVariants({ variant: "outline" })}>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<style>
|
||||||
|
rect { fill: #171717; } text { fill: #fafafa; }
|
||||||
|
@media (prefers-color-scheme: dark) { rect { fill: #fafafa; } text { fill: #171717; } }
|
||||||
|
</style>
|
||||||
|
<rect width="32" height="32" rx="7"/>
|
||||||
|
<text x="16" y="21.5" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="700">T5</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 397 B |
@@ -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 (
|
||||||
|
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
|
||||||
|
<body className="flex min-h-dvh flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">{children}</main>
|
||||||
|
<footer className="border-t">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-6 text-sm text-muted-foreground sm:px-6">
|
||||||
|
T5DE is unofficial: not made by or affiliated with IMVU.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<NarrowCard title="Not found" description="There's nothing at this address.">
|
||||||
|
<Link href="/" className={buttonVariants({ variant: "outline" })}>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Online in T5DE">IMVU accounts in a T5DE client right now, that chose to show it.</PageHeader>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<OnlineList initial={online} moderator={isModerator(me)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-14">
|
||||||
|
<section className="max-w-2xl space-y-5 pt-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">T5DE Hub</p>
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
|
||||||
|
One account for all your IMVU avatars.
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-pretty text-muted-foreground">
|
||||||
|
Link your IMVU accounts, see who's online in T5DE, and soon, community products.
|
||||||
|
</p>
|
||||||
|
{!me && (
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<Link href="/signup" className={buttonVariants({ size: "lg", className: "px-4" })}>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
<Link href="/signin" className={buttonVariants({ size: "lg", variant: "outline", className: "px-4" })}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Online in T5DE</CardDescription>
|
||||||
|
<CardTitle className="text-4xl font-semibold tabular-nums">{home.onlineCount}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
people showing that they're online.{" "}
|
||||||
|
<Link href="/online" className="text-foreground underline underline-offset-4">
|
||||||
|
See who
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{links !== undefined ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Your IMVU accounts</CardDescription>
|
||||||
|
<CardTitle className="text-4xl font-semibold tabular-nums">{links}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
linked.{" "}
|
||||||
|
<Link href="/account" className="text-foreground underline underline-offset-4">
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>One account, all your avatars</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
Link any of your IMVU accounts by putting a code in its tagline. Your accounts stay private: nobody else
|
||||||
|
can see they're yours.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<NarrowCard title="Sign in" description="to your T5DE account.">
|
||||||
|
<SignInForm next={next} />
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit(new FormData(e.currentTarget));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="username">Username</FieldLabel>
|
||||||
|
<Input id="username" name="username" autoComplete="username" required autoFocus />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" name="password" type="password" autoComplete="current-password" required />
|
||||||
|
</Field>
|
||||||
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
|
<Field>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Signing in…" : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
<FieldDescription className="text-center">
|
||||||
|
No account? <Link href="/signup">Create one</Link>
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<NarrowCard title="Create account" description="One T5DE account for all your IMVU accounts.">
|
||||||
|
<SignUpForm />
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit(new FormData(e.currentTarget));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="username">Username</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
pattern="[A-Za-z0-9_]{3,32}"
|
||||||
|
title="3 to 32 letters, digits or underscores"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" name="password" type="password" autoComplete="new-password" required minLength={10} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password2">Password again</FieldLabel>
|
||||||
|
<Input id="password2" name="password2" type="password" autoComplete="new-password" required minLength={10} />
|
||||||
|
<FieldDescription>Not your IMVU password: use a new one.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
|
<Field>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Creating account…" : "Create account"}
|
||||||
|
</Button>
|
||||||
|
<FieldDescription className="text-center">
|
||||||
|
Have one? <Link href="/signin">Sign in</Link>
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ActionState>;
|
||||||
|
|
||||||
|
export function ActionForm({
|
||||||
|
action,
|
||||||
|
fields = {},
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
action: Action;
|
||||||
|
/** Hidden fields sent with the form. */
|
||||||
|
fields?: Record<string, string | number>;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [state, formAction] = useActionState(action, null);
|
||||||
|
return (
|
||||||
|
<form action={formAction} className={cn("space-y-3", className)}>
|
||||||
|
{Object.entries(fields).map(([name, value]) => (
|
||||||
|
<input key={name} type="hidden" name={name} value={value} />
|
||||||
|
))}
|
||||||
|
{children}
|
||||||
|
<FormMessage state={state} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormMessage({ state }: { state: ActionState }) {
|
||||||
|
if (state?.error) return <ErrorText>{state.error}</ErrorText>;
|
||||||
|
if (state?.notice)
|
||||||
|
return (
|
||||||
|
<p role="status" className="text-sm text-muted-foreground">
|
||||||
|
{state.notice}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorText({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p role="alert" className="text-sm text-red-700 dark:text-red-400">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<React.ComponentProps<typeof Button>, "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 (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
disabled={status.pending}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (confirm && !window.confirm(confirm)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{mine ? (pendingLabel ?? children) : children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-current={here ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2.5 py-1.5 text-muted-foreground transition-colors hover:text-foreground",
|
||||||
|
here && "bg-muted text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignOutButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={async () => {
|
||||||
|
setPending(true);
|
||||||
|
await authClient.signOut();
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending ? "Signing out…" : "Sign out"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className="grid size-7 place-items-center rounded-md bg-foreground text-[11px] font-bold tracking-tight text-background"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
T5
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function Header() {
|
||||||
|
const me = await getMe();
|
||||||
|
return (
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
|
||||||
|
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||||
|
<Logo />
|
||||||
|
T5DE Hub
|
||||||
|
</Link>
|
||||||
|
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto">
|
||||||
|
<NavLink href="/online">Online</NavLink>
|
||||||
|
{me && <NavLink href="/account">Account</NavLink>}
|
||||||
|
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||||
|
{me ? (
|
||||||
|
<>
|
||||||
|
<span className="hidden text-muted-foreground sm:inline">{me.username}</span>
|
||||||
|
<SignOutButton />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link href="/signup" className={buttonVariants()}>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card className="mx-auto w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">{title}</CardTitle>
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
{children && <CardContent>{children}</CardContent>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={live ? "size-2 rounded-full bg-emerald-500" : "size-2 rounded-full bg-muted-foreground/40"}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{live ? "Live" : "Connecting…"}
|
||||||
|
<span className="ml-auto tabular-nums">
|
||||||
|
{online.length} {online.length === 1 ? "account" : "accounts"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{online.length === 0 ? (
|
||||||
|
<p className="py-6 text-sm text-muted-foreground">Nobody is showing that they're online right now.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{online.map((o) => (
|
||||||
|
<li key={o.imvuId} className="flex gap-4 py-4 first:pt-0 last:pb-0">
|
||||||
|
<Initial name={o.username} />
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{o.username}</span>
|
||||||
|
{o.badge && <Tag tone="strong">T5DE</Tag>}
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
online since <Since iso={o.since} />
|
||||||
|
</Tag>
|
||||||
|
{o.hidden && <Tag>hidden: moderators only</Tag>}
|
||||||
|
{moderator && o.adult && <Tag tone="adult">adult</Tag>}
|
||||||
|
{moderator && o.personId && (
|
||||||
|
<Link
|
||||||
|
href={`/console/people/${o.personId}`}
|
||||||
|
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
|
>
|
||||||
|
person
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Outfit items={o.outfit} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-6 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium whitespace-nowrap",
|
||||||
|
tones[tone],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dot && <span className="size-1.5 rounded-full bg-current" aria-hidden />}
|
||||||
|
<span>{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A round initial standing in for an avatar picture. */
|
||||||
|
export function Initial({ name }: { name: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="grid size-10 shrink-0 place-items-center rounded-full border bg-muted text-sm font-semibold text-muted-foreground uppercase"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The community products an account wears. */
|
||||||
|
export function Outfit({ items }: { items: { id: string; name: string }[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs text-muted-foreground">Wearing</div>
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="rounded-md border bg-background px-2 py-0.5 text-xs">
|
||||||
|
{item.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page's title and the line under it. */
|
||||||
|
export function PageHeader({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8 space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
|
||||||
|
{children && <div className="text-muted-foreground">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An IMVU account's age status: blue when it can see adult community products. */
|
||||||
|
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) {
|
||||||
|
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>;
|
||||||
|
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
|
||||||
|
return <Tag>Not age verified</Tag>;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
||||||
|
{local ? ago(iso, now) : format(iso, { dateStyle: "medium" }, false)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <time dateTime={iso}>{format(iso, options, local)}</time>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
||||||
|
{format(iso, options, local)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-medium group-has-[>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 (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
@@ -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<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>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 (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 has-[>[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 (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none 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 { Input }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelect({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: NativeSelectProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="native-select-wrapper"
|
||||||
|
data-size={size}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
data-slot="native-select"
|
||||||
|
data-size={size}
|
||||||
|
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"option">) {
|
||||||
|
return (
|
||||||
|
<option
|
||||||
|
data-slot="native-select-option"
|
||||||
|
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOptGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"optgroup">) {
|
||||||
|
return (
|
||||||
|
<optgroup
|
||||||
|
data-slot="native-select-optgroup"
|
||||||
|
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Server actions answer forms with an ActionState: an error to show, or a notice.
|
||||||
|
import "server-only";
|
||||||
|
import { ApiError } from "./api";
|
||||||
|
|
||||||
|
export type ActionState = { error?: string; notice?: string } | null;
|
||||||
|
|
||||||
|
/** Runs fn, turning the API's errors into a message for the form; fn may return a notice. */
|
||||||
|
export async function attempt(fn: () => Promise<string | void>): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const notice = await fn();
|
||||||
|
return notice ? { notice } : {};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.message };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A form field as a string ("" when missing). */
|
||||||
|
export function field(form: FormData, name: string): string {
|
||||||
|
const v = form.get(name);
|
||||||
|
return typeof v === "string" ? v.trim() : "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// The hub's API, called from server components and server actions. Calls go to the API's internal address
|
||||||
|
// (API_URL) with the visitor's cookie, so the API sees the same session the browser has.
|
||||||
|
import "server-only";
|
||||||
|
import { treaty } from "@elysiajs/eden";
|
||||||
|
import type { App } from "@t5de/api";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
const apiURL = process.env.API_URL ?? "http://localhost:4101";
|
||||||
|
|
||||||
|
/** The visitor's cookie and address, to pass on to the API. */
|
||||||
|
async function forwarded(): Promise<Record<string, string>> {
|
||||||
|
const incoming = await headers();
|
||||||
|
const forward: Record<string, string> = {};
|
||||||
|
for (const name of ["cookie", "x-forwarded-for", "user-agent"]) {
|
||||||
|
const value = incoming.get(name);
|
||||||
|
if (value) forward[name] = value;
|
||||||
|
}
|
||||||
|
return forward;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API's /api/v1 routes, as the person making this request. (Never await the client itself: it's a proxy, and
|
||||||
|
* awaiting it would call a route named "then".)
|
||||||
|
*/
|
||||||
|
export const api = treaty<App>(apiURL, { headers: forwarded }).api.v1;
|
||||||
|
|
||||||
|
type V1 = typeof api;
|
||||||
|
type Problem = { error: string; message: string };
|
||||||
|
/** What a route answers when it succeeds (the API's error shape is part of its declared answers). */
|
||||||
|
type Ok<T> = Exclude<T, Problem>;
|
||||||
|
type Data<Route extends (...args: any[]) => Promise<{ data: unknown }>> = Ok<
|
||||||
|
NonNullable<Awaited<ReturnType<Route>>["data"]>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type Person = Data<V1["me"]["get"]>["person"];
|
||||||
|
export type Account = Data<V1["account"]["get"]>;
|
||||||
|
export type Link = Account["links"][number];
|
||||||
|
export type Challenge = Account["challenges"][number];
|
||||||
|
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
|
||||||
|
export type ConsoleData = Data<V1["console"]["get"]>;
|
||||||
|
export type AuditEntry = ConsoleData["audit"][number];
|
||||||
|
|
||||||
|
/** An error the API answered: message is written to be shown as is. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly slug: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Answer<T> = { data: T; error: null } | { data: null; error: { status: unknown; value: unknown } };
|
||||||
|
|
||||||
|
/** The answer's data, or throws ApiError with the API's message. */
|
||||||
|
export function unwrap<T>(answer: Answer<T>): Ok<NonNullable<T>> {
|
||||||
|
if (answer.error) {
|
||||||
|
const status = typeof answer.error.status === "number" ? answer.error.status : 500;
|
||||||
|
const value = answer.error.value as { error?: unknown; message?: unknown } | null;
|
||||||
|
const slug = typeof value?.error === "string" ? value.error : "error";
|
||||||
|
const message =
|
||||||
|
typeof value?.message === "string" && value.message
|
||||||
|
? value.message
|
||||||
|
: "Something went wrong. Try again in a moment.";
|
||||||
|
throw new ApiError(status, slug, message);
|
||||||
|
}
|
||||||
|
return answer.data as Ok<NonNullable<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who's signed in, or undefined. Once per request. */
|
||||||
|
export const getMe = cache(async (): Promise<Person | undefined> => {
|
||||||
|
const { data } = await api.me.get();
|
||||||
|
return data && "person" in data ? data.person : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isModerator = (p: Person | undefined) => p?.role === "moderator" || p?.role === "admin";
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Better Auth in the browser: sign-up, sign-in and sign-out. Its requests go to /api/auth on this site, which Next
|
||||||
|
// rewrites to the API, so the session cookie is set for this site.
|
||||||
|
"use client";
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { usernameClient } from "better-auth/client/plugins";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: typeof window === "undefined" ? undefined : window.location.origin,
|
||||||
|
plugins: [usernameClient()],
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** next if it's a path on this site, else fallback (so ?next= can't send people elsewhere). */
|
||||||
|
export function safeNext(next: string | string[] | undefined | null, fallback = "/account"): string {
|
||||||
|
if (typeof next !== "string") return fallback;
|
||||||
|
if (!next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return fallback;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { cn } from "cn";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
// The API's internal address. The browser never calls it directly: /api is rewritten to it, so the API's session
|
||||||
|
// cookies are first-party on the website's origin. Rewrites are fixed when the app is built, so API_URL must be set
|
||||||
|
// at build time as well as at run time (server components call it directly).
|
||||||
|
const apiURL = process.env.API_URL ?? "http://localhost:4101";
|
||||||
|
|
||||||
|
// The monorepo's root: standalone output traces workspace packages from here.
|
||||||
|
const root = path.join(import.meta.dirname, "../..");
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
outputFileTracingRoot: root,
|
||||||
|
turbopack: { root },
|
||||||
|
// Next's gzip holds back the API's event stream (who's online); compress in front of the app instead (Vercel and
|
||||||
|
// most reverse proxies do).
|
||||||
|
compress: false,
|
||||||
|
agentRules: false,
|
||||||
|
async rewrites() {
|
||||||
|
return [{ source: "/api/:path*", destination: `${apiURL}/api/:path*` }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@t5de/web",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 4100",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 4100",
|
||||||
|
"check-types": "next typegen && tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.9.0",
|
||||||
|
"@elysiajs/eden": "1.4.9",
|
||||||
|
"@t5de/api": "workspace:*",
|
||||||
|
"better-auth": "1.7.14",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cn": "^0.5.0",
|
||||||
|
"lucide-react": "^1.64.0",
|
||||||
|
"next": "16.4.3",
|
||||||
|
"react": "19.3.1",
|
||||||
|
"react-dom": "19.3.1",
|
||||||
|
"server-only": "^0.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/node": "^26.6.6",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"elysia": "1.4.30",
|
||||||
|
"shadcn": "^4.23.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user