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