Files
server-26/drb-frontend/app/profile/page.tsx
T
Logan CusanoandClaude Opus 5.5 bf206334e3 fix(frontend): finish the token migration — raw grey classes -> theme tokens
UI_REDESIGN chunk 4 was never finished: 39 app/component files still used
raw Tailwind greys, kept readable in light mode only by the !important
override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the
light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both.

Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2,
500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised,
border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder.
Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700,
text-white and marketing pages left alone. text-[9px] -> text-[10px].

Frontend unverified: no Node on the authoring box (#207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:29:13 -04:00

223 lines
7.7 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
interface LinkStatus {
linked: boolean;
discord_user_id?: string;
discord_username?: string;
linked_at?: string;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
});
}
function Initials({ name }: { name: string }) {
const parts = name.trim().split(/\s+/);
const letters = parts.length >= 2
? parts[0][0] + parts[parts.length - 1][0]
: name.slice(0, 2);
return (
<div className="w-16 h-16 rounded-full bg-indigo-700 flex items-center justify-center text-white text-xl font-bold select-none">
{letters.toUpperCase()}
</div>
);
}
export default function ProfilePage() {
const { user, isAdmin, role, signOut } = useAuth();
const router = useRouter();
const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null);
const [linkLoading, setLinkLoading] = useState(true);
const [code, setCode] = useState<string | null>(null);
const [codeExpiry, setCodeExpiry] = useState<number | null>(null);
const [generating, setGenerating] = useState(false);
const [unlinking, setUnlinking] = useState(false);
useEffect(() => {
if (!user) return;
c2api.getLinkStatus()
.then(setLinkStatus)
.catch(() => setLinkStatus({ linked: false }))
.finally(() => setLinkLoading(false));
}, [user]);
async function generateCode() {
setGenerating(true);
try {
const res = await c2api.generateLinkCode();
if (res.already_linked) {
setLinkStatus((prev) => prev ? { ...prev, linked: true } : prev);
} else if (res.code) {
setCode(res.code);
setCodeExpiry(res.expires_minutes ?? 15);
}
} finally {
setGenerating(false);
}
}
async function unlink() {
setUnlinking(true);
try {
await c2api.unlinkDiscord();
setLinkStatus({ linked: false });
setCode(null);
} finally {
setUnlinking(false);
}
}
async function handleSignOut() {
await signOut();
router.push("/login");
}
if (!user) return null;
const displayName = user.displayName || user.email || "Account";
return (
<div className="max-w-lg space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Initials name={displayName} />
<div>
<h1 className="text-white text-xl font-bold">{displayName}</h1>
{user.displayName && user.email && (
<p className="text-ink-2 text-sm mt-0.5">{user.email}</p>
)}
{role && (
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
role === "admin" ? "bg-indigo-900 text-indigo-300" :
role === "operator" ? "bg-green-900 text-green-300" :
"bg-raised text-ink-2"
}`}>
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
</span>
)}
</div>
</div>
{/* Firebase account */}
<section className="bg-surface border border-line rounded-xl divide-y divide-line">
<div className="px-4 py-3">
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-2">Account</p>
<div className="space-y-2">
<Row label="Email" value={user.email ?? "—"} />
<Row label="UID" value={user.uid} mono truncate />
<Row label="Role" value={role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} />
{user.metadata.creationTime && (
<Row label="Joined" value={fmtDate(user.metadata.creationTime)} />
)}
{user.metadata.lastSignInTime && (
<Row label="Last sign-in" value={fmtDate(user.metadata.lastSignInTime)} />
)}
</div>
</div>
</section>
{/* Discord linking */}
<section className="bg-surface border border-line rounded-xl overflow-hidden">
<div className="px-4 py-3">
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-3">Discord</p>
{linkLoading ? (
<p className="text-ink-muted text-sm">Loading…</p>
) : linkStatus?.linked ? (
<div className="space-y-3">
<div className="space-y-2">
{linkStatus.discord_username && (
<Row label="Username" value={`@${linkStatus.discord_username}`} />
)}
{linkStatus.discord_user_id && (
<Row label="User ID" value={linkStatus.discord_user_id} mono />
)}
{linkStatus.linked_at && (
<Row label="Linked" value={fmtDate(linkStatus.linked_at)} />
)}
</div>
<div className="pt-1">
<button
onClick={unlink}
disabled={unlinking}
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-50"
>
{unlinking ? "Unlinking…" : "Unlink Discord account"}
</button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-ink-2">
Link your Discord account to access private trips from both the web and Discord.
</p>
{code ? (
<div className="space-y-2">
<div className="bg-raised rounded-lg px-4 py-3 flex items-center gap-3">
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
</div>
<p className="text-xs text-ink-2">
Run <span className="font-mono text-ink">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
</p>
<button
onClick={generateCode}
disabled={generating}
className="text-xs text-ink-muted hover:text-ink transition-colors disabled:opacity-50"
>
Generate new code
</button>
</div>
) : (
<button
onClick={generateCode}
disabled={generating}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2 transition-colors"
>
{generating ? "Generating…" : "Get link code"}
</button>
)}
</div>
)}
</div>
</section>
{/* Sign out */}
<section className="bg-surface border border-line rounded-xl">
<div className="px-4 py-3 flex items-center justify-between">
<p className="text-sm text-ink-2">Sign out of this device</p>
<button
onClick={handleSignOut}
className="text-sm text-red-500 hover:text-red-400 transition-colors"
>
Sign out
</button>
</div>
</section>
</div>
);
}
function Row({ label, value, mono = false, truncate = false }: {
label: string;
value: string;
mono?: boolean;
truncate?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-4">
<span className="text-xs text-ink-muted shrink-0">{label}</span>
<span className={`text-sm text-ink text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
{value}
</span>
</div>
);
}