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