feat(web): add profile page with user info and preferences #482
467
apps/web/src/app/(authenticated)/profile/page.tsx
Normal file
467
apps/web/src/app/(authenticated)/profile/page.tsx
Normal 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 }}>— {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user