mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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:
parent
47f581cc1d
commit
3acfa9eb93
4 changed files with 145 additions and 62 deletions
|
|
@ -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),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
69
apps/fabro-web/app/routes/profile-overview.tsx
Normal file
69
apps/fabro-web/app/routes/profile-overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/fabro-web/app/routes/profile-sessions.tsx
Normal file
3
apps/fabro-web/app/routes/profile-sessions.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function ProfileSessions() {
|
||||
return <div />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue