nxtgauge-admin-solid/src/components/AdminShell.tsx

293 lines
11 KiB
TypeScript
Raw Normal View History

import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router';
import { For, createEffect, createMemo, createSignal, onCleanup, onMount, type JSX } from 'solid-js';
import AdminSidebar from './AdminSidebar';
import { isExternalIdentity } from '~/lib/admin-auth';
import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session';
import { Bell } from 'lucide-solid';
type Tab = { href: string; label: string; exact?: boolean };
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
{
prefixes: ['/admin/roles'],
tabs: [
{ href: '/admin/roles', label: 'Roles', exact: true },
{ href: '/admin/roles/create', label: 'Create Role' },
{ href: '/admin/roles/templates', label: 'View Roles' },
],
},
{
prefixes: ['/admin/runtime-roles'],
tabs: [
{ href: '/admin/runtime-roles', label: 'Roles', exact: true },
{ href: '/admin/runtime-roles/new', label: 'Create Role' },
{ href: '/admin/role-ui-configs', label: 'View Roles' },
],
},
];
export default function AdminShell(props: { children: JSX.Element }) {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [checkedSession, setCheckedSession] = createSignal(false);
const [adminName, setAdminName] = createSignal('Admin');
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
const [tabsTrackEl, setTabsTrackEl] = createSignal<HTMLDivElement>();
const [tabRefs, setTabRefs] = createSignal<Record<string, HTMLAnchorElement>>({});
const [tabIndicator, setTabIndicator] = createSignal({ left: 0, width: 0, ready: false });
const tabs = createMemo<Tab[]>(() => {
const path = location.pathname;
for (const set of TAB_SETS) {
if (set.prefixes.some((p) => path === p || path.startsWith(`${p}/`))) return set.tabs;
}
return [];
});
const isTabActive = (tab: Tab) =>
tab.exact
? location.pathname === tab.href
: location.pathname === tab.href || location.pathname.startsWith(`${tab.href}/`);
const refreshTabIndicator = () => {
const activeTab = tabs().find((tab) => isTabActive(tab));
const track = tabsTrackEl();
if (!activeTab || !track) {
setTabIndicator((prev) => ({ ...prev, ready: false }));
return;
}
const el = tabRefs()[activeTab.href];
if (!el) return;
setTabIndicator({ left: el.offsetLeft, width: el.offsetWidth, ready: true });
};
createEffect(() => {
tabs();
location.pathname;
requestAnimationFrame(refreshTabIndicator);
});
onMount(() => {
const onResize = () => refreshTabIndicator();
window.addEventListener('resize', onResize);
onCleanup(() => window.removeEventListener('resize', onResize));
const isLocalDev =
typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const isPreview =
searchParams._preview === '1' ||
(typeof sessionStorage !== 'undefined' &&
sessionStorage.getItem('nxtgauge_admin_preview') === '1');
if (isPreview || isLocalDev) {
if (typeof sessionStorage !== 'undefined')
sessionStorage.setItem('nxtgauge_admin_preview', '1');
setAdminSession();
setCheckedSession(true);
return;
}
const verify = async () => {
if (!hasAdminSession()) {
const from = encodeURIComponent(location.pathname + location.search);
navigate(`/login?from=${from}`, { replace: true });
return;
}
try {
const accessToken =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const response = await fetch('/api/gateway/users/auth/me', {
method: 'GET',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || isExternalIdentity(payload)) throw new Error('Unauthorized');
if (payload?.full_name) setAdminName(payload.full_name);
setCheckedSession(true);
} catch {
clearAdminSession();
const from = encodeURIComponent(location.pathname + location.search);
navigate(`/login?from=${from}`, { replace: true });
}
};
void verify();
});
const onLogout = async () => {
await fetch('/api/gateway/users/auth/logout', {
method: 'POST',
headers: { Accept: 'application/json', 'x-portal-target': 'admin' },
credentials: 'include',
}).catch(() => {});
clearAdminSession();
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('nxtgauge_admin_access_token');
sessionStorage.removeItem('nxtgauge_admin_preview');
}
navigate('/login', { replace: true });
};
const adminInitials = createMemo(() => {
const parts = adminName().split(' ').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return 'U';
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase();
return `${parts[0][0] || ''}`.toUpperCase();
});
const sidebarWidth = () => (sidebarCollapsed() ? 'w-20' : 'w-64');
return (
<div class="min-h-screen bg-white">
{/* ── Header ── */}
<header class="fixed inset-x-0 top-0 z-50 flex h-16 w-full items-center justify-between border-b border-gray-200 bg-white px-6 shadow-sm">
{/* Left: logo + role title */}
<div class="flex items-center gap-8">
<A href="/admin" class="flex h-10 items-center">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-9 w-auto object-contain" />
</A>
<h1 class="text-base font-semibold text-gray-800">Super Admin</h1>
</div>
{/* Mobile menu button */}
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-600 hover:bg-slate-100 lg:hidden"
onClick={() => setSidebarOpen((v) => !v)}
aria-label="Toggle sidebar"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
{/* Right: bell + avatar + logout */}
<div class="hidden items-center gap-6 lg:flex">
{/* Bell */}
<div class="relative">
<button
type="button"
aria-label="Notifications"
class="relative rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100"
>
<Bell size={20} />
</button>
</div>
{/* Avatar + name */}
<div class="flex items-center gap-2">
<button class="flex items-center gap-3 rounded-lg p-1 pr-2 transition-colors hover:bg-gray-100">
<div class="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full border border-orange-200 bg-orange-100 text-sm font-semibold text-orange-700">
{adminInitials()}
</div>
<div class="flex flex-col items-start">
<span class="mb-0.5 text-xs font-semibold leading-none text-gray-700">{adminName()}</span>
<span class="text-[10px] leading-none text-gray-500">Super Admin</span>
</div>
</button>
{/* Logout */}
<button
type="button"
onClick={onLogout}
class="rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600"
aria-label="Sign out"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</header>
{/* ── Body ── */}
{checkedSession() ? (
<div class="fixed inset-0 top-16 flex">
{/* Mobile overlay */}
<div
class={`absolute inset-0 z-20 bg-black/30 transition-opacity lg:hidden ${sidebarOpen() ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
onClick={() => setSidebarOpen(false)}
/>
{/* Sidebar */}
<div
class={`absolute inset-y-0 left-0 z-30 -translate-x-full transition-transform duration-200 lg:static lg:translate-x-0 ${sidebarOpen() ? 'translate-x-0' : ''}`}
>
<AdminSidebar
collapsed={sidebarCollapsed()}
onToggle={() => setSidebarCollapsed((v) => !v)}
onNavigate={() => setSidebarOpen(false)}
onLogout={onLogout}
/>
</div>
{/* Main */}
<main class="scrollbar min-w-0 flex-1 overflow-y-auto bg-gray-50 p-6">
<ShowTabs
tabs={tabs()}
isTabActive={isTabActive}
setTabsTrackEl={setTabsTrackEl}
setTabRefs={setTabRefs}
tabIndicator={tabIndicator}
/>
{props.children}
</main>
</div>
) : (
<div class="fixed inset-0 top-16 flex">
<div class="hidden w-64 border-r border-slate-200 bg-[#fcfcfd] lg:block" />
<main class="flex flex-1 items-center justify-center bg-gray-50">
<p class="text-sm text-gray-400">Checking session</p>
</main>
</div>
)}
</div>
);
}
function ShowTabs(props: {
tabs: Tab[];
isTabActive: (tab: Tab) => boolean;
setTabsTrackEl: (el: HTMLDivElement) => void;
setTabRefs: (fn: (prev: Record<string, HTMLAnchorElement>) => Record<string, HTMLAnchorElement>) => void;
tabIndicator: () => { left: number; width: number; ready: boolean };
}) {
if (props.tabs.length === 0) return null;
return (
<div ref={props.setTabsTrackEl} class="relative mb-6 flex items-center gap-1 border-b border-gray-200">
<For each={props.tabs}>
{(tab) => (
<A
href={tab.href}
ref={(el) => props.setTabRefs((prev) => ({ ...prev, [tab.href]: el }))}
aria-current={props.isTabActive(tab) ? 'page' : undefined}
class={`px-4 pb-3 pt-3 text-[14px] font-semibold transition-colors ${
props.isTabActive(tab)
? 'text-[#fd6216]'
: 'text-slate-500 hover:text-slate-800'
}`}
>
{tab.label}
</A>
)}
</For>
<div
class={`absolute bottom-0 h-[2px] bg-[#fd6216] transition-all duration-300 ease-out ${props.tabIndicator().ready ? 'opacity-100' : 'opacity-0'}`}
style={{ left: `${props.tabIndicator().left}px`, width: `${props.tabIndicator().width}px` }}
/>
</div>
);
}