diff --git a/drb-frontend/app/profile/page.tsx b/drb-frontend/app/profile/page.tsx new file mode 100644 index 0000000..9827391 --- /dev/null +++ b/drb-frontend/app/profile/page.tsx @@ -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 ( +
+ {letters.toUpperCase()} +
+ ); +} + +export default function ProfilePage() { + const { user, isAdmin, signOut } = useAuth(); + const router = useRouter(); + + const [linkStatus, setLinkStatus] = useState(null); + const [linkLoading, setLinkLoading] = useState(true); + const [code, setCode] = useState(null); + const [codeExpiry, setCodeExpiry] = useState(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 ( +
+ {/* Header */} +
+ +
+

{displayName}

+ {user.displayName && user.email && ( +

{user.email}

+ )} + {isAdmin && ( + + Admin + + )} +
+
+ + {/* Firebase account */} +
+
+

Account

+
+ + + + {user.metadata.creationTime && ( + + )} + {user.metadata.lastSignInTime && ( + + )} +
+
+
+ + {/* Discord linking */} +
+
+

Discord

+ + {linkLoading ? ( +

Loading…

+ ) : linkStatus?.linked ? ( +
+
+ {linkStatus.discord_username && ( + + )} + {linkStatus.discord_user_id && ( + + )} + {linkStatus.linked_at && ( + + )} +
+
+ +
+
+ ) : ( +
+

+ Link your Discord account to access private trips from both the web and Discord. +

+ {code ? ( +
+
+ {code} +
+

+ Run /link {code} in Discord. Code expires in {codeExpiry} minutes. +

+ +
+ ) : ( + + )} +
+ )} +
+
+ + {/* Sign out */} +
+
+

Sign out of this device

+ +
+
+
+ ); +} + +function Row({ label, value, mono = false, truncate = false }: { + label: string; + value: string; + mono?: boolean; + truncate?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/drb-frontend/app/trips/page.tsx b/drb-frontend/app/trips/page.tsx index 29aaf7c..8ada768 100644 --- a/drb-frontend/app/trips/page.tsx +++ b/drb-frontend/app/trips/page.tsx @@ -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(null); - const [codeExpiry, setCodeExpiry] = useState(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 ( -
- - Discord linked{status.discord_username ? ` as @${status.discord_username}` : ""}. - - -
- ); - } - - return ( -
-
-
-

Link your Discord account

-

- Required to access private trips from Discord or the web. -

-
- {!code && ( - - )} -
- {code && ( -
- {code} - - Run /link {code} in Discord. Expires in {codeExpiry}m. -
- )} -
- ); -} - 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 (
- -

Trips

{isAdmin && ( diff --git a/drb-frontend/components/Nav.tsx b/drb-frontend/components/Nav.tsx index 9632068..f100afe 100644 --- a/drb-frontend/components/Nav.tsx +++ b/drb-frontend/components/Nav.tsx @@ -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" ? : } - {/* Sign out (desktop) */} + {/* Profile avatar (desktop) */} {/* Hamburger (mobile) */} @@ -154,12 +160,15 @@ export function Nav() { ))}
- + Profile +
)}