New /profile page
Avatar (initials) + display name, email, admin badge Account section: email, UID, role, join date, last sign-in Discord section: link status with username/user ID/linked date, or the get-code flow if unlinked, plus unlink button Sign out button at the bottom
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
"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, 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-gray-400 text-sm mt-0.5">{user.email}</p>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<span className="inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full bg-indigo-900 text-indigo-300">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Firebase account */}
|
||||
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-gray-500 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={isAdmin ? "Admin" : "Member"} />
|
||||
{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-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p>
|
||||
|
||||
{linkLoading ? (
|
||||
<p className="text-gray-500 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-gray-400">
|
||||
Link your Discord account to access private trips from both the web and Discord.
|
||||
</p>
|
||||
{code ? (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-gray-800 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-gray-400">
|
||||
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
|
||||
</p>
|
||||
<button
|
||||
onClick={generateCode}
|
||||
disabled={generating}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 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-gray-900 border border-gray-800 rounded-xl">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-400">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-gray-500 shrink-0">{label}</span>
|
||||
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useTrips } from "@/lib/useTrips";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { TripRecord } from "@/lib/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discord link banner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DiscordLinkBanner() {
|
||||
const [status, setStatus] = useState<{ linked: boolean; discord_username?: string } | null>(null);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [codeExpiry, setCodeExpiry] = useState<number | null>(null); // minutes
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [unlinking, setUnlinking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
c2api.getLinkStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function generateCode() {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const res = await c2api.generateLinkCode();
|
||||
if (res.already_linked) {
|
||||
setStatus((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();
|
||||
setStatus({ linked: false });
|
||||
setCode(null);
|
||||
} finally {
|
||||
setUnlinking(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === null) return null; // still loading
|
||||
|
||||
if (status.linked) {
|
||||
return (
|
||||
<div className="flex items-center justify-between bg-gray-900 border border-gray-800 rounded-xl px-4 py-3 text-sm">
|
||||
<span className="text-gray-400">
|
||||
Discord linked{status.discord_username ? ` as @${status.discord_username}` : ""}.
|
||||
</span>
|
||||
<button
|
||||
onClick={unlink}
|
||||
disabled={unlinking}
|
||||
className="text-xs text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{unlinking ? "Unlinking…" : "Unlink"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-amber-800/40 rounded-xl px-4 py-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-white font-medium">Link your Discord account</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Required to access private trips from Discord or the web.
|
||||
</p>
|
||||
</div>
|
||||
{!code && (
|
||||
<button
|
||||
onClick={generateCode}
|
||||
disabled={generating}
|
||||
className="shrink-0 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-xs rounded-lg px-3 py-1.5 transition-colors"
|
||||
>
|
||||
{generating ? "Generating…" : "Get link code"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{code && (
|
||||
<div className="flex items-center gap-3 bg-gray-800 rounded-lg px-3 py-2">
|
||||
<span className="font-mono text-lg tracking-[0.3em] text-white select-all">{code}</span>
|
||||
<span className="text-gray-500 text-xs">—</span>
|
||||
<span className="text-gray-400 text-xs">Run <span className="font-mono text-gray-300">/link {code}</span> in Discord. Expires in {codeExpiry}m.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
@@ -273,8 +183,6 @@ export default function TripsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<DiscordLinkBanner />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Trips</h1>
|
||||
{isAdmin && (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
@@ -49,8 +49,9 @@ function MoonIcon() {
|
||||
}
|
||||
|
||||
export function Nav() {
|
||||
const { user, isAdmin, signOut } = useAuth();
|
||||
const { user, isAdmin } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { nodes: pending } = useUnconfiguredNodes();
|
||||
const unackedAlerts = useUnacknowledgedAlerts();
|
||||
const { theme, toggle } = useTheme();
|
||||
@@ -101,12 +102,17 @@ export function Nav() {
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
|
||||
{/* Sign out (desktop) */}
|
||||
{/* Profile avatar (desktop) */}
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
onClick={() => router.push("/profile")}
|
||||
className={`hidden md:flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
|
||||
pathname.startsWith("/profile")
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
||||
}`}
|
||||
title="Profile"
|
||||
>
|
||||
Sign out
|
||||
{(user?.displayName || user?.email || "?")[0].toUpperCase()}
|
||||
</button>
|
||||
|
||||
{/* Hamburger (mobile) */}
|
||||
@@ -154,12 +160,15 @@ export function Nav() {
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-gray-800 pt-3 mt-1">
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
||||
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
Profile
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user