Shell (AdminShell + AdminSidebar): - Logo moved to header-left section, width syncs with sidebar collapse state - Global search bar with debounced multi-module API calls and grouped dropdown - Bell notification icon with badge, gear → /admin/settings, user dropdown with logout - Sidebar: 7 grouped nav sections with dividers, orange left-border active state, removed "Active" badge pill, user info (avatar + name + role) pinned to bottom - Fixed all sidebar labels to match Figma (Employee Management, External Onboarding Management, Users Management, Verification Management) - Added missing sidebar items: Verification, Fitness Trainers, Graphic Designers, Social Media, Video Editors, Catering Services, Applications, Responses Dashboard (admin/index): - Rebuilt to match Figma: "Dashboard Overview" title, Export Report button - 4 stat cards (Total Users, Active Companies, Open Leads, Credits Purchased) with real API fetch, orange icons, delta badges, graceful — fallback - ApexCharts: Leads Trend (orange gradient line) + Revenue Overview (navy bars) - Drag-and-drop widget system via @thisbeyond/solid-dnd — sortable stat cards and chart cards with handles and remove buttons in Customise mode - Add Widget panel shows all available widgets not on dashboard - 8 stat widgets + 3 chart widgets available; layout persists in localStorage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
612 lines
23 KiB
TypeScript
612 lines
23 KiB
TypeScript
import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router';
|
|
import {
|
|
For, Show, 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, Search, Settings, LogOut, User, ChevronDown } from 'lucide-solid';
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type Tab = { href: string; label: string; exact?: boolean };
|
|
|
|
type SearchResult = { id: string; title: string; subtitle: string; href: string };
|
|
type SearchGroup = { label: string; viewAllHref: string; results: SearchResult[] };
|
|
|
|
// ── Tab sets ─────────────────────────────────────────────────────────────────
|
|
|
|
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' },
|
|
],
|
|
},
|
|
];
|
|
|
|
// ── Global search ─────────────────────────────────────────────────────────────
|
|
|
|
const SEARCH_MODULES = [
|
|
{
|
|
label: 'Users',
|
|
viewAllHref: '/admin/users',
|
|
api: '/api/gateway/api/admin/users',
|
|
listKeys: ['users', 'items'],
|
|
titleKeys: ['full_name', 'name'],
|
|
subtitleKeys: ['email', 'phone'],
|
|
detailBase: '/admin/users',
|
|
},
|
|
{
|
|
label: 'Companies',
|
|
viewAllHref: '/admin/company',
|
|
api: '/api/gateway/api/admin/companies',
|
|
listKeys: ['companies', 'items'],
|
|
titleKeys: ['name', 'companyName'],
|
|
subtitleKeys: ['email', 'phone'],
|
|
detailBase: '/admin/company',
|
|
},
|
|
{
|
|
label: 'Employees',
|
|
viewAllHref: '/admin/employees',
|
|
api: '/api/gateway/api/admin/employees',
|
|
listKeys: ['employees', 'items'],
|
|
titleKeys: ['full_name', 'name'],
|
|
subtitleKeys: ['email', 'department_name'],
|
|
detailBase: '/admin/employees',
|
|
},
|
|
{
|
|
label: 'Jobs',
|
|
viewAllHref: '/admin/jobs',
|
|
api: '/api/gateway/api/admin/jobs',
|
|
listKeys: ['jobs', 'items'],
|
|
titleKeys: ['title', 'name'],
|
|
subtitleKeys: ['status', 'company_name'],
|
|
detailBase: '/admin/jobs',
|
|
},
|
|
{
|
|
label: 'Leads',
|
|
viewAllHref: '/admin/leads',
|
|
api: '/api/gateway/api/admin/leads',
|
|
listKeys: ['leads', 'items'],
|
|
titleKeys: ['name', 'full_name'],
|
|
subtitleKeys: ['email', 'status'],
|
|
detailBase: '/admin/leads',
|
|
},
|
|
{
|
|
label: 'Candidates',
|
|
viewAllHref: '/admin/candidate',
|
|
api: '/api/gateway/api/admin/candidates',
|
|
listKeys: ['candidates', 'items'],
|
|
titleKeys: ['full_name', 'name'],
|
|
subtitleKeys: ['email', 'phone'],
|
|
detailBase: '/admin/candidate',
|
|
},
|
|
];
|
|
|
|
function pickStr(obj: Record<string, any>, keys: string[]): string {
|
|
for (const k of keys) if (obj[k]) return String(obj[k]);
|
|
return '—';
|
|
}
|
|
|
|
function extractList(data: any, keys: string[]): any[] {
|
|
if (Array.isArray(data)) return data;
|
|
for (const k of keys) if (Array.isArray(data[k])) return data[k];
|
|
return [];
|
|
}
|
|
|
|
function GlobalSearch() {
|
|
const [query, setQuery] = createSignal('');
|
|
const [open, setOpen] = createSignal(false);
|
|
const [groups, setGroups] = createSignal<SearchGroup[]>([]);
|
|
const [searching, setSearching] = createSignal(false);
|
|
let wrapRef!: HTMLDivElement;
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
|
|
const doSearch = async (q: string) => {
|
|
const trimmed = q.trim();
|
|
if (trimmed.length < 2) { setGroups([]); setOpen(false); return; }
|
|
setSearching(true);
|
|
const settled = await Promise.allSettled(
|
|
SEARCH_MODULES.map(async (mod) => {
|
|
const res = await fetch(
|
|
`${mod.api}?search=${encodeURIComponent(trimmed)}&limit=4`,
|
|
).catch(() => null);
|
|
if (!res?.ok) return null;
|
|
const data = await res.json().catch(() => null);
|
|
if (!data) return null;
|
|
const items = extractList(data, mod.listKeys).slice(0, 4);
|
|
if (!items.length) return null;
|
|
return {
|
|
label: mod.label,
|
|
viewAllHref: mod.viewAllHref,
|
|
results: items.map((item: any) => ({
|
|
id: item.id,
|
|
title: pickStr(item, mod.titleKeys),
|
|
subtitle: pickStr(item, mod.subtitleKeys),
|
|
href: `${mod.detailBase}/${item.id}`,
|
|
})),
|
|
} satisfies SearchGroup;
|
|
}),
|
|
);
|
|
setGroups(settled.flatMap((r) => (r.status === 'fulfilled' && r.value ? [r.value] : [])));
|
|
setOpen(true);
|
|
setSearching(false);
|
|
};
|
|
|
|
const handleInput = (val: string) => {
|
|
setQuery(val);
|
|
clearTimeout(timer);
|
|
if (val.trim().length < 2) { setGroups([]); setOpen(false); return; }
|
|
timer = setTimeout(() => doSearch(val), 400);
|
|
};
|
|
|
|
const close = () => { setOpen(false); setQuery(''); setGroups([]); };
|
|
|
|
const onOutside = (e: MouseEvent) => {
|
|
if (!wrapRef.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
onMount(() => document.addEventListener('mousedown', onOutside));
|
|
onCleanup(() => document.removeEventListener('mousedown', onOutside));
|
|
|
|
return (
|
|
<div ref={wrapRef!} class="relative flex-1 max-w-[480px]">
|
|
{/* Search icon */}
|
|
<Search
|
|
size={15}
|
|
class="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400"
|
|
/>
|
|
|
|
<input
|
|
type="text"
|
|
placeholder="Search for anything..."
|
|
value={query()}
|
|
onInput={(e) => handleInput(e.currentTarget.value)}
|
|
onFocus={() => groups().length > 0 && setOpen(true)}
|
|
onKeyDown={(e) => e.key === 'Escape' && close()}
|
|
class="h-10 w-full rounded-xl border border-gray-200 bg-gray-50 pl-10 pr-4 text-sm text-gray-700 placeholder:text-gray-400 transition-colors focus:border-gray-300 focus:bg-white focus:outline-none"
|
|
/>
|
|
|
|
{/* Results dropdown */}
|
|
<Show when={open() && groups().length > 0}>
|
|
<div class="absolute left-0 right-0 top-[calc(100%+6px)] z-[200] max-h-[500px] overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-2xl shadow-black/10">
|
|
<For each={groups()}>
|
|
{(group) => (
|
|
<div class="border-b border-gray-100 last:border-0">
|
|
<div class="flex items-center justify-between px-4 pt-3 pb-1.5">
|
|
<span class="text-[10px] font-black uppercase tracking-widest text-gray-400">
|
|
{group.label}
|
|
</span>
|
|
<A
|
|
href={group.viewAllHref}
|
|
onClick={close}
|
|
class="text-[11px] font-semibold text-orange-500 hover:text-orange-600"
|
|
>
|
|
View all →
|
|
</A>
|
|
</div>
|
|
<For each={group.results}>
|
|
{(item) => (
|
|
<A
|
|
href={item.href}
|
|
onClick={close}
|
|
class="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-gray-50"
|
|
>
|
|
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-100 text-sm font-bold text-orange-600">
|
|
{item.title.trim().slice(0, 1).toUpperCase()}
|
|
</div>
|
|
<div class="min-w-0">
|
|
<p class="truncate text-sm font-semibold text-gray-800">{item.title}</p>
|
|
<p class="truncate text-xs text-gray-500">{item.subtitle}</p>
|
|
</div>
|
|
</A>
|
|
)}
|
|
</For>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* No results */}
|
|
<Show when={open() && !searching() && query().trim().length >= 2 && groups().length === 0}>
|
|
<div class="absolute left-0 right-0 top-[calc(100%+6px)] z-[200] rounded-xl border border-gray-200 bg-white px-4 py-6 text-center shadow-2xl shadow-black/10">
|
|
<p class="text-sm text-gray-500">
|
|
No results for "<span class="font-semibold">{query()}</span>"
|
|
</p>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Notification bell ─────────────────────────────────────────────────────────
|
|
|
|
function NotificationBell(props: { count: number }) {
|
|
const [open, setOpen] = createSignal(false);
|
|
let ref!: HTMLDivElement;
|
|
|
|
const onOutside = (e: MouseEvent) => {
|
|
if (!ref.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
onMount(() => document.addEventListener('mousedown', onOutside));
|
|
onCleanup(() => document.removeEventListener('mousedown', onOutside));
|
|
|
|
return (
|
|
<div ref={ref!} class="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-label="Notifications"
|
|
class="relative flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100"
|
|
>
|
|
<Bell size={19} />
|
|
<Show when={props.count > 0}>
|
|
<span class="absolute right-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center rounded-full bg-orange-500 ring-2 ring-white" />
|
|
</Show>
|
|
</button>
|
|
|
|
<Show when={open()}>
|
|
<div class="absolute right-0 top-[calc(100%+6px)] z-[200] w-80 rounded-xl border border-gray-200 bg-white shadow-2xl shadow-black/10">
|
|
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
|
<span class="text-[13px] font-bold text-gray-800">Notifications</span>
|
|
<Show when={props.count > 0}>
|
|
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-[11px] font-bold text-orange-600">
|
|
{props.count} new
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
<div class="flex flex-col items-center justify-center py-10 text-center">
|
|
<Bell size={28} class="mb-3 text-gray-300" />
|
|
<p class="text-sm font-medium text-gray-500">Real-time notifications</p>
|
|
<p class="mt-1 text-xs text-gray-400">Connecting to live feed…</p>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── User menu ─────────────────────────────────────────────────────────────────
|
|
|
|
function UserMenu(props: { name: string; initials: string; onLogout: () => void }) {
|
|
const [open, setOpen] = createSignal(false);
|
|
let ref!: HTMLDivElement;
|
|
|
|
const onOutside = (e: MouseEvent) => {
|
|
if (!ref.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
onMount(() => document.addEventListener('mousedown', onOutside));
|
|
onCleanup(() => document.removeEventListener('mousedown', onOutside));
|
|
|
|
return (
|
|
<div ref={ref!} class="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-50"
|
|
>
|
|
<div class="text-right hidden sm:block">
|
|
<p class="text-[13px] font-semibold leading-tight text-gray-800">{props.name}</p>
|
|
<p class="text-[11px] leading-tight text-gray-500">Super Admin</p>
|
|
</div>
|
|
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-500 text-sm font-bold text-white">
|
|
{props.initials}
|
|
</div>
|
|
</button>
|
|
|
|
<Show when={open()}>
|
|
<div class="absolute right-0 top-[calc(100%+6px)] z-[200] w-48 rounded-xl border border-gray-200 bg-white py-1 shadow-2xl shadow-black/10">
|
|
<A
|
|
href="/admin/profile"
|
|
onClick={() => setOpen(false)}
|
|
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50"
|
|
>
|
|
<User size={15} class="text-gray-400" />
|
|
My Profile
|
|
</A>
|
|
<A
|
|
href="/admin/settings"
|
|
onClick={() => setOpen(false)}
|
|
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50"
|
|
>
|
|
<Settings size={15} class="text-gray-400" />
|
|
Settings
|
|
</A>
|
|
<div class="my-1 border-t border-gray-100" />
|
|
<button
|
|
type="button"
|
|
onClick={() => { setOpen(false); props.onLogout(); }}
|
|
class="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-red-600 transition-colors hover:bg-red-50"
|
|
>
|
|
<LogOut size={15} />
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ── Main shell ────────────────────────────────────────────────────────────────
|
|
|
|
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 User');
|
|
const [sidebarOpen, setSidebarOpen] = createSignal(false);
|
|
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
|
|
const [notifCount] = createSignal(0); // wired in Phase 2 (SSE)
|
|
|
|
const [tabsTrackEl, setTabsTrackEl] = createSignal<HTMLDivElement>();
|
|
const [tabRefs, setTabRefs] = createSignal<Record<string, HTMLAnchorElement>>({});
|
|
const [tabIndicator, setTabIndicator] = createSignal({ left: 0, width: 0, ready: false });
|
|
|
|
// ── Tab logic ──────────────────────────────────────────────────────────────
|
|
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((p) => ({ ...p, 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(() => {
|
|
window.addEventListener('resize', refreshTabIndicator);
|
|
onCleanup(() => window.removeEventListener('resize', refreshTabIndicator));
|
|
|
|
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()) {
|
|
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, { 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();
|
|
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, { 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, 2).toUpperCase();
|
|
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
|
});
|
|
|
|
// Sidebar width — synced between header logo section and sidebar
|
|
const sidebarW = () => (sidebarCollapsed() ? 'w-[72px]' : '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 border-b border-gray-200 bg-white">
|
|
|
|
{/* Logo section — same width as sidebar */}
|
|
<div
|
|
class={`flex shrink-0 items-center justify-center border-r border-gray-200 transition-all duration-300 h-full ${sidebarW()}`}
|
|
>
|
|
<A href="/admin">
|
|
<img
|
|
src="/nxtgauge-icon.png"
|
|
alt="Nxtgauge"
|
|
class="h-8 w-8 object-contain"
|
|
/>
|
|
</A>
|
|
</div>
|
|
|
|
{/* Search bar */}
|
|
<div class="flex flex-1 items-center px-5">
|
|
<GlobalSearch />
|
|
</div>
|
|
|
|
{/* Mobile menu button */}
|
|
<button
|
|
type="button"
|
|
class="mr-4 flex h-9 w-9 items-center justify-center rounded-lg text-gray-500 hover:bg-gray-100 lg:hidden"
|
|
onClick={() => setSidebarOpen((v) => !v)}
|
|
aria-label="Toggle sidebar"
|
|
>
|
|
<svg class="h-5 w-5" 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 + Gear + User */}
|
|
<div class="hidden items-center gap-1 pr-4 lg:flex">
|
|
<NotificationBell count={notifCount()} />
|
|
|
|
<A
|
|
href="/admin/settings"
|
|
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100"
|
|
aria-label="Settings"
|
|
>
|
|
<Settings size={19} />
|
|
</A>
|
|
|
|
<div class="mx-2 h-6 w-px bg-gray-200" />
|
|
|
|
<UserMenu
|
|
name={adminName()}
|
|
initials={adminInitials()}
|
|
onLogout={onLogout}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ── Body ── */}
|
|
<Show
|
|
when={checkedSession()}
|
|
fallback={
|
|
<div class="fixed inset-0 top-16 flex">
|
|
<div class={`hidden border-r border-gray-200 bg-white lg:block shrink-0 ${sidebarW()}`} />
|
|
<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 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)}
|
|
adminName={adminName()}
|
|
adminInitials={adminInitials()}
|
|
/>
|
|
</div>
|
|
|
|
{/* Main content */}
|
|
<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>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|