feat(web): add profile page with user info and preferences
Some checks failed
ci/woodpecker/push/web Pipeline failed

Refs #468
This commit is contained in:
2026-02-22 22:48:48 -06:00
parent f30c2f790c
commit 2708e065fe

View File

@@ -0,0 +1,467 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { ReactElement } from "react";
import Link from "next/link";
import { useAuth } from "@/lib/auth/auth-context";
import { apiGet } from "@/lib/api/client";
// ─── Types ────────────────────────────────────────────────────────────
interface UserPreferences {
id: string;
userId: string;
theme: string;
locale: string;
timezone: string | null;
settings: Record<string, unknown>;
updatedAt: string;
}
// ─── Sub-components ───────────────────────────────────────────────────
interface PreferenceRowProps {
label: string;
value: string;
}
function PreferenceRow({ label, value }: PreferenceRowProps): ReactElement {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 0",
borderBottom: "1px solid var(--border)",
}}
>
<span style={{ fontSize: "0.9rem", color: "var(--text-2)" }}>{label}</span>
<span
style={{
fontSize: "0.9rem",
fontWeight: 500,
color: "var(--text)",
fontFamily: "var(--mono)",
}}
>
{value}
</span>
</div>
);
}
function PreferencesSkeleton(): ReactElement {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
style={{
display: "flex",
justifyContent: "space-between",
padding: "12px 0",
borderBottom: "1px solid var(--border)",
}}
>
<div
style={{
width: 80,
height: 16,
borderRadius: 4,
background: "var(--surface-2)",
animation: "pulse 1.5s ease-in-out infinite",
}}
/>
<div
style={{
width: 120,
height: 16,
borderRadius: 4,
background: "var(--surface-2)",
animation: "pulse 1.5s ease-in-out infinite",
}}
/>
</div>
))}
</div>
);
}
// ─── Main Page Component ──────────────────────────────────────────────
export default function ProfilePage(): ReactElement {
const { user, signOut } = useAuth();
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
const [prefsLoading, setPrefsLoading] = useState(true);
const [prefsError, setPrefsError] = useState<string | null>(null);
const [signOutHovered, setSignOutHovered] = useState(false);
const [settingsHovered, setSettingsHovered] = useState(false);
const loadPreferences = useCallback(async (): Promise<void> => {
setPrefsLoading(true);
setPrefsError(null);
try {
const data = await apiGet<UserPreferences>("/users/me/preferences");
setPreferences(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Could not load preferences";
setPrefsError(message);
} finally {
setPrefsLoading(false);
}
}, []);
useEffect(() => {
void loadPreferences();
}, [loadPreferences]);
// User initials for avatar fallback
const initials = user?.name
? user.name
.split(" ")
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase()
: user?.email
? (user.email[0] ?? "?").toUpperCase()
: "?";
return (
<div className="max-w-3xl mx-auto p-6">
{/* ── Page Header ── */}
<div style={{ marginBottom: 32 }}>
<h1
style={{
fontSize: "1.875rem",
fontWeight: 700,
color: "var(--text)",
margin: 0,
}}
>
Profile
</h1>
<p
style={{
fontSize: "0.9rem",
color: "var(--muted)",
margin: "8px 0 0 0",
}}
>
Your account information and preferences
</p>
</div>
{/* ── User Info Card ── */}
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-xl)",
padding: 28,
marginBottom: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 20 }}>
{/* Avatar (64px) */}
<div
style={{
width: 64,
height: 64,
borderRadius: "50%",
background: user?.image
? "none"
: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
overflow: "hidden",
}}
>
{user?.image ? (
<img
src={user.image}
alt={user.name || user.email || "User avatar"}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<span
style={{
fontSize: "1.25rem",
fontWeight: 700,
color: "#fff",
letterSpacing: "0.02em",
lineHeight: 1,
}}
>
{initials}
</span>
)}
</div>
{/* Name, email, role, status */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<h2
style={{
fontSize: "1.25rem",
fontWeight: 700,
color: "var(--text)",
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user?.name ?? "User"}
</h2>
{/* Online indicator */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--success)",
boxShadow: "0 0 6px var(--success)",
flexShrink: 0,
}}
aria-hidden="true"
/>
<span
style={{
fontSize: "0.75rem",
color: "var(--success)",
fontWeight: 500,
}}
>
Online
</span>
</div>
</div>
{user?.email && (
<p
style={{
fontSize: "0.9rem",
color: "var(--muted)",
margin: "4px 0 0 0",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.email}
</p>
)}
{user?.workspaceRole && (
<span
style={{
display: "inline-block",
marginTop: 8,
padding: "3px 10px",
borderRadius: "var(--r)",
background: "rgba(47, 128, 255, 0.1)",
color: "var(--ms-blue-400)",
fontSize: "0.75rem",
fontWeight: 600,
textTransform: "capitalize",
}}
>
{user.workspaceRole}
</span>
)}
</div>
</div>
</div>
{/* ── Preferences Section ── */}
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-xl)",
padding: 28,
marginBottom: 24,
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: 600,
color: "var(--text)",
margin: "0 0 16px 0",
}}
>
Preferences
</h3>
{prefsLoading ? (
<PreferencesSkeleton />
) : prefsError ? (
<div
style={{
padding: "16px 20px",
borderRadius: "var(--r)",
background: "rgba(245, 158, 11, 0.08)",
border: "1px solid rgba(245, 158, 11, 0.2)",
color: "var(--text-2)",
fontSize: "0.85rem",
lineHeight: 1.5,
}}
>
<span style={{ fontWeight: 500 }}>Preferences unavailable</span>
<span style={{ color: "var(--muted)", marginLeft: 8 }}>&mdash; {prefsError}</span>
</div>
) : preferences ? (
<div>
<PreferenceRow label="Theme" value={preferences.theme} />
<PreferenceRow label="Locale" value={preferences.locale} />
<PreferenceRow label="Timezone" value={preferences.timezone ?? "Not set"} />
{Object.keys(preferences.settings).length > 0 && (
<>
<div
style={{
fontSize: "0.83rem",
fontWeight: 600,
color: "var(--text-2)",
margin: "16px 0 8px 0",
}}
>
Custom Settings
</div>
{Object.entries(preferences.settings).map(([key, value]) => (
<PreferenceRow key={key} label={key} value={String(value)} />
))}
</>
)}
</div>
) : (
<p
style={{
fontSize: "0.9rem",
color: "var(--muted)",
margin: 0,
}}
>
No preferences configured yet.
</p>
)}
</div>
{/* ── Account Actions ── */}
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-xl)",
padding: 28,
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: 600,
color: "var(--text)",
margin: "0 0 16px 0",
}}
>
Account
</h3>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Settings link */}
<Link
href="/settings"
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 20px",
borderRadius: "var(--r)",
background: settingsHovered ? "var(--surface-2)" : "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text)",
fontSize: "0.9rem",
fontWeight: 500,
textDecoration: "none",
cursor: "pointer",
transition: "background 0.15s ease, border-color 0.15s ease",
}}
onMouseEnter={() => {
setSettingsHovered(true);
}}
onMouseLeave={() => {
setSettingsHovered(false);
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="2.5" />
<path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" />
</svg>
Settings
</Link>
{/* Sign Out button */}
<button
onClick={() => {
void signOut();
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 20px",
borderRadius: "var(--r)",
background: signOutHovered ? "rgba(239, 68, 68, 0.1)" : "transparent",
border: "1px solid var(--danger)",
color: "var(--danger)",
fontSize: "0.9rem",
fontWeight: 500,
cursor: "pointer",
transition: "background 0.15s ease",
}}
onMouseEnter={() => {
setSignOutHovered(true);
}}
onMouseLeave={() => {
setSignOutHovered(false);
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M10 11l4-4-4-4M14 8H6" />
</svg>
Sign Out
</button>
</div>
</div>
</div>
);
}