mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-29 00:01:22 +00:00
Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup
Backend: - GET /services/:id/metrics with real Docker container stats - DELETE /git/providers/:id and GET repo branches endpoint Frontend: - Service detail shows real telemetry or honest empty states - Live status distinguishes idle/polling/reconnecting - Git providers section in Settings; repo+branch pickers in Add Service - Real logo, compact Projects header, canvas fitView fix - index.css deduplicated (1725 -> 1384 lines) - Fixed smoke tests, lint errors, ToastProvider/context unification Repo hygiene: - Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README - Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore - Removed legacy root src/internal/cmd and stale configs/docs/artifacts - Moved guides and design references under docs/ Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
355a97bab4
commit
9f3b196032
499 files changed
+4471
-94235
No files matched your search
@@ -1,22 +1,35 @@
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
FolderKanban,
|
||||
Hammer,
|
||||
LayoutTemplate,
|
||||
ChartBar,
|
||||
Users,
|
||||
Settings,
|
||||
BookOpen,
|
||||
Container,
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChartBar,
|
||||
CheckCircle2,
|
||||
Container,
|
||||
FolderKanban,
|
||||
LayoutTemplate,
|
||||
LogOut,
|
||||
Moon,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Zap,
|
||||
Activity,
|
||||
Settings,
|
||||
Sun,
|
||||
UploadCloud,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getUpgradeStatus,
|
||||
listAuditLogs,
|
||||
listBuilds,
|
||||
pullUpgradeImage,
|
||||
} from '@/lib/api-client';
|
||||
import { signOutAuthSession } from '@/lib/auth-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useToast } from '@/shared/components';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban },
|
||||
{ label: 'Builds', href: '/builds', icon: Hammer },
|
||||
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
|
||||
{ label: 'Usage', href: '/usage', icon: ChartBar },
|
||||
{ label: 'People', href: '/people', icon: Users },
|
||||
@@ -24,240 +37,299 @@ const navItems = [
|
||||
{ label: 'Docs', href: '/docs', icon: BookOpen },
|
||||
];
|
||||
|
||||
// Developer/Demo pages
|
||||
const demoNavItems = [
|
||||
{ label: 'Metrics Demo', href: '/metrics-demo', icon: Activity },
|
||||
{ label: 'Showcase', href: '/showcase', icon: Zap },
|
||||
];
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
function getInitialTheme(): ThemeMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark';
|
||||
}
|
||||
return localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
export function PlatformShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1';
|
||||
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
|
||||
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
|
||||
const isActiveRoute = (itemHref: string) =>
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const upgradeQuery = useQuery({
|
||||
queryKey: ['upgrade-status'],
|
||||
queryFn: getUpgradeStatus,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const buildsQuery = useQuery({
|
||||
queryKey: ['shell-build-notifications'],
|
||||
queryFn: () => listBuilds({ page: 1, limit: 5 }),
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const auditQuery = useQuery({
|
||||
queryKey: ['shell-audit-notifications'],
|
||||
queryFn: () => listAuditLogs({ page: 1, limit: 5 }),
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem('containr.theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const pullMutation = useMutation({
|
||||
mutationFn: pullUpgradeImage,
|
||||
onSuccess: (status) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upgrade-status'] });
|
||||
showToast('success', 'Upgrade image pulled', status.digest || status.imageRef || status.message);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast('error', 'Upgrade failed', error instanceof Error ? error.message : 'Unable to pull image');
|
||||
},
|
||||
});
|
||||
|
||||
const notifications = useMemo(() => {
|
||||
const builds = (buildsQuery.data?.builds ?? []).map((build) => ({
|
||||
id: `build-${build.id}`,
|
||||
title: `${build.status} build`,
|
||||
body: build.imageName || build.serviceId || build.id,
|
||||
}));
|
||||
const audits = (auditQuery.data ?? []).map((log) => ({
|
||||
id: `audit-${log.id}`,
|
||||
title: log.action,
|
||||
body: `${log.resource}${log.resourceId ? ` / ${log.resourceId}` : ''}`,
|
||||
}));
|
||||
return [...builds, ...audits].slice(0, 6);
|
||||
}, [auditQuery.data, buildsQuery.data?.builds]);
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
await signOutAuthSession();
|
||||
} finally {
|
||||
queryClient.clear();
|
||||
navigate('/auth/sign-in', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const isActiveRoute = (itemHref: string) =>
|
||||
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
||||
|
||||
const userName = sessionQuery.data?.user.name ?? 'Account';
|
||||
const userEmail = sessionQuery.data?.user.email ?? 'Local session';
|
||||
const initials = userName
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'C';
|
||||
|
||||
return (
|
||||
<div className="app-shell min-h-screen">
|
||||
{/* Ambient glow background */}
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<div className="flex min-h-screen relative">
|
||||
{/* Desktop Sidebar - self.html exact match: 64px width */}
|
||||
<aside
|
||||
className="hidden md:flex shrink-0 flex-col items-center border-r"
|
||||
style={{
|
||||
width: '64px',
|
||||
background: '#111217',
|
||||
borderRight: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '16px 0',
|
||||
gap: '5px'
|
||||
|
||||
<div className="relative flex min-h-screen">
|
||||
<aside
|
||||
className="hidden h-screen shrink-0 flex-col border-r md:flex"
|
||||
style={{
|
||||
width: '232px',
|
||||
background: 'var(--bg-base)',
|
||||
borderRightColor: 'var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
{/* Logo - exact self.html: 38px circular pink */}
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
width: '38px',
|
||||
height: '38px',
|
||||
background: '#e8316a',
|
||||
marginBottom: '14px'
|
||||
}}
|
||||
<NavLink
|
||||
to={href('/projects')}
|
||||
className="flex h-[58px] shrink-0 items-center gap-3 border-b px-4"
|
||||
style={{ borderBottomColor: 'var(--border-subtle)' }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Nav Items - 40x40 icon-only */}
|
||||
{navItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator */}
|
||||
<div style={{ width: '32px', height: '1px', background: 'rgba(255,255,255,0.07)', margin: '8px 0' }} />
|
||||
|
||||
{/* Demo/Developer Items */}
|
||||
{demoNavItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Bottom icons - Settings */}
|
||||
<NavLink to="/settings" className="nav-item" title="Settings">
|
||||
<Settings size={18} />
|
||||
<img src="/containr.svg" alt="Containr" className="h-9 w-9 rounded-[var(--radius-md)]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)]">Containr</p>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)]">Deploy platform</p>
|
||||
</div>
|
||||
</NavLink>
|
||||
<NavLink to="/docs" className="nav-item" title="Help">
|
||||
<BookOpen size={18} />
|
||||
</NavLink>
|
||||
|
||||
{/* User Avatar - exact self.html: 34px */}
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center cursor-pointer"
|
||||
style={{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
background: '#22233a',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: '#9295a4',
|
||||
marginTop: '4px',
|
||||
letterSpacing: '-0.3px'
|
||||
}}
|
||||
title="User"
|
||||
>
|
||||
w.
|
||||
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-1 px-3 py-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`flex h-10 items-center gap-3 rounded-[var(--radius-md)] px-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={17} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 border-t p-3" style={{ borderTopColor: 'var(--border-subtle)' }}>
|
||||
<NavLink
|
||||
to={href('/settings')}
|
||||
className="mb-2 flex items-center gap-3 rounded-[var(--radius-md)] p-2 hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--surface-card-hover)] text-xs font-semibold text-[var(--text-primary)]">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{userName}</p>
|
||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
|
||||
</div>
|
||||
</NavLink>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NavLink
|
||||
to={href('/settings')}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-[var(--radius-md)] border text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
>
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-[var(--radius-md)] border text-xs font-medium text-[var(--error)] hover:bg-[var(--error-soft)]"
|
||||
style={{ borderColor: 'var(--error-soft)' }}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="min-w-0 flex-1 relative z-10 flex flex-col min-h-screen overflow-hidden">
|
||||
{/* Topbar - self.html exact match: 52px height */}
|
||||
<header
|
||||
className="shrink-0 flex items-center"
|
||||
style={{
|
||||
height: '52px',
|
||||
background: '#111217',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '0 22px',
|
||||
gap: '14px'
|
||||
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header
|
||||
className="hidden h-[58px] shrink-0 items-center border-b px-5 md:flex"
|
||||
style={{
|
||||
background: 'var(--bg-base)',
|
||||
borderBottomColor: 'var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
{/* Search Box */}
|
||||
<div className="search-box">
|
||||
<NavLink to={href('/projects')} className="mr-4 flex items-center gap-2 text-[var(--text-primary)]">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
|
||||
<Container size={15} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold">Containr</span>
|
||||
</NavLink>
|
||||
|
||||
<div className="search-box max-w-[420px]">
|
||||
<Search size={14} />
|
||||
<input type="text" placeholder="Search logs..." />
|
||||
<input type="text" placeholder="Search projects, builds, logs..." />
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex items-center" style={{ gap: '8px' }}>
|
||||
<button
|
||||
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
Support
|
||||
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
gap: '6px'
|
||||
}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pullMutation.mutate()}
|
||||
disabled={isDemoMode || pullMutation.isPending || !upgradeQuery.data?.imageRef}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ background: '#e8316a' }}
|
||||
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<polyline points="17 11 12 6 7 11"/>
|
||||
<polyline points="17 18 12 13 7 18"/>
|
||||
</svg>
|
||||
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
|
||||
Upgrade
|
||||
</button>
|
||||
<button
|
||||
className="rounded-[9px] border flex items-center justify-center cursor-pointer bg-transparent"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotificationsOpen((open) => !open)}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={15} />
|
||||
{notifications.length > 0 ? (
|
||||
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-[var(--accent-primary)]" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{notificationsOpen ? (
|
||||
<div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Build and audit activity</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shell-build-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['shell-audit-notifications'] });
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
aria-label="Refresh notifications"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map((item) => (
|
||||
<div key={item.id} className="rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{item.title}</p>
|
||||
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
|
||||
<CheckCircle2 size={15} className="text-[var(--success)]" />
|
||||
No recent events
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/70 backdrop-blur-2xl">
|
||||
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
background: '#e8316a'
|
||||
}}
|
||||
>
|
||||
<Container size={16} className="text-white" />
|
||||
<NavLink to={href('/projects')} className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-white">
|
||||
<Container size={16} />
|
||||
</div>
|
||||
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all">
|
||||
<Search size={16} />
|
||||
</button>
|
||||
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all relative">
|
||||
<Bell size={16} />
|
||||
<div className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[var(--accent-primary)]" />
|
||||
</button>
|
||||
</div>
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
className="h-9 w-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)]"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={15} className="mx-auto" /> : <Moon size={15} className="mx-auto" />}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex gap-1 px-3 pb-3 overflow-x-auto scrollbar-hide">
|
||||
<nav className="flex gap-1 overflow-x-auto px-3 pb-3">
|
||||
{navItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
|
||||
className={`flex items-center gap-1.5 whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{/* Demo items */}
|
||||
{demoNavItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
@@ -268,7 +340,6 @@ export function PlatformShell() {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Page Content - scrollable */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
Reference in new issue
Block a user