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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user