feat(web): add profile sub-navigation with Sessions

Wraps /profile in a left-sidebar layout. Existing profile page becomes
the index; adds a placeholder /profile/sessions. Renames the identity
rows from "IdP issuer"/"IdP subject" to "Issuer"/"Subject".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-10 12:40:50 -04:00
parent 47f581cc1d
commit 3acfa9eb93
No known key found for this signature in database
4 changed files with 145 additions and 62 deletions

View file

@ -31,6 +31,8 @@ import * as Settings from "./routes/settings";
import * as SettingsOverview from "./routes/settings-overview";
import * as SettingsLiveEvents from "./routes/settings-live-events";
import * as Profile from "./routes/profile";
import * as ProfileOverview from "./routes/profile-overview";
import * as ProfileSessions from "./routes/profile-sessions";
import AppShellModule from "./layouts/app-shell";
type RouteModule = {
@ -121,7 +123,12 @@ export const routes: RouteObject[] = [
route("live-events", SettingsLiveEvents),
],
}),
route("profile", Profile),
route("profile", Profile, {
children: [
indexRoute(ProfileOverview),
route("sessions", ProfileSessions),
],
}),
],
},
],

View file

@ -0,0 +1,69 @@
import type { AuthSessionUser } from "@qltysh/fabro-api-client";
import { useAuthMe } from "../lib/queries";
import {
Mono,
Muted,
Panel,
PanelSkeleton,
Row,
UrlValue,
} from "../components/settings-panel";
export default function ProfileOverview() {
const { data: auth } = useAuthMe();
if (!auth) {
return (
<div className="space-y-6">
<PanelSkeleton />
<PanelSkeleton />
</div>
);
}
const { user } = auth;
return (
<div className="space-y-6">
<BasicsPanel user={user} />
<IdentityPanel user={user} />
</div>
);
}
function BasicsPanel({ user }: { user: AuthSessionUser }) {
return (
<Panel title="Basics">
<Row title="Name">
<div className="flex items-center gap-3">
<img
alt=""
src={user.avatarUrl}
className="size-10 rounded-full outline -outline-offset-1 outline-line-strong"
/>
<span>{user.name}</span>
</div>
</Row>
<Row title="Username">
<Mono>{user.login}</Mono>
</Row>
<Row title="Email">{user.email}</Row>
</Panel>
);
}
function IdentityPanel({ user }: { user: AuthSessionUser }) {
return (
<Panel title="Identity">
<Row title="Issuer">
{user.idpIssuer ? <Mono>{user.idpIssuer}</Mono> : <Muted>—</Muted>}
</Row>
<Row title="Subject">
{user.idpSubject ? <Mono>{user.idpSubject}</Mono> : <Muted>—</Muted>}
</Row>
<Row title="Profile URL">
<UrlValue url={user.userUrl} />
</Row>
</Panel>
);
}

View file

@ -0,0 +1,3 @@
export default function ProfileSessions() {
return <div />;
}

View file

@ -1,73 +1,77 @@
import type { AuthSessionUser } from "@qltysh/fabro-api-client";
import { useAuthMe } from "../lib/queries";
import {
Mono,
Muted,
Panel,
PanelSkeleton,
Row,
UrlValue,
} from "../components/settings-panel";
import { ComputerDesktopIcon, UserCircleIcon } from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation } from "react-router";
export function meta({}: any) {
return [{ title: "Profile — Fabro" }];
}
export default function Profile() {
const { data: auth } = useAuthMe();
export const handle = { hideHeader: true };
if (!auth) {
return (
<div className="space-y-6">
<PanelSkeleton />
<PanelSkeleton />
</div>
);
}
type NavItem = {
name: string;
href: string;
icon: typeof UserCircleIcon;
match: (pathname: string) => boolean;
};
const { user } = auth;
const navItems: NavItem[] = [
{
name: "Profile",
href: "/profile",
icon: UserCircleIcon,
match: (p) => p === "/profile",
},
{
name: "Sessions",
href: "/profile/sessions",
icon: ComputerDesktopIcon,
match: (p) => p.startsWith("/profile/sessions"),
},
];
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
export default function ProfileLayout() {
const { pathname } = useLocation();
const currentName = navItems.find((item) => item.match(pathname))?.name ?? "Profile";
return (
<div className="space-y-6">
<BasicsPanel user={user} />
<IdentityPanel user={user} />
<div className="flex flex-col gap-6 lg:flex-row">
<aside className="lg:w-56 lg:shrink-0">
<nav className="sticky top-6">
<ul role="list" className="flex gap-1 overflow-x-auto lg:flex-col lg:gap-0.5">
{navItems.map((item) => {
const current = item.match(pathname);
return (
<li key={item.name}>
<Link
to={item.href}
aria-current={current ? "page" : undefined}
className={classNames(
"flex items-center gap-2 rounded-md px-2.5 py-2 text-sm whitespace-nowrap transition-colors",
current
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg",
)}
>
<item.icon className="size-4 shrink-0" aria-hidden="true" />
{item.name}
</Link>
</li>
);
})}
</ul>
</nav>
</aside>
<div className="min-w-0 flex-1">
<h1 className="mb-2 text-xl font-semibold tracking-tight text-fg">
{currentName}
</h1>
<Outlet />
</div>
</div>
);
}
function BasicsPanel({ user }: { user: AuthSessionUser }) {
return (
<Panel title="Basics">
<Row title="Name">
<div className="flex items-center gap-3">
<img
alt=""
src={user.avatarUrl}
className="size-10 rounded-full outline -outline-offset-1 outline-line-strong"
/>
<span>{user.name}</span>
</div>
</Row>
<Row title="Username">
<Mono>{user.login}</Mono>
</Row>
<Row title="Email">{user.email}</Row>
</Panel>
);
}
function IdentityPanel({ user }: { user: AuthSessionUser }) {
return (
<Panel title="Identity">
<Row title="IdP issuer">
{user.idpIssuer ? <Mono>{user.idpIssuer}</Mono> : <Muted>—</Muted>}
</Row>
<Row title="IdP subject">
{user.idpSubject ? <Mono>{user.idpSubject}</Mono> : <Muted>—</Muted>}
</Row>
<Row title="Profile URL">
<UrlValue url={user.userUrl} />
</Row>
</Panel>
);
}