feat(ui): replace sidebar with compact top header navigation
- Rewrite MainLayout from vertical sidebar to horizontal header bar - Brand left, nav tabs center, status indicator right - Simplify StatusBar to single inline layout (remove collapsed variant) - Widen content area from max-w-4xl to max-w-6xl - Remove sidebar-collapsed localStorage persistence
This commit is contained in:
1 parent
0753bbdce6
commit
7d2977b665
3 files changed
+94
-253
No files matched your search
@@ -3,15 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { isProcessRunning, startGeminiNexus, closeGeminiNexus } from '@/actions/process';
|
import { isProcessRunning, startGeminiNexus, closeGeminiNexus } from '@/actions/process';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Play, Square, Loader2, Power } from 'lucide-react';
|
import { Play, Square, Loader2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
export const StatusBar: React.FC = () => {
|
||||||
|
|
||||||
interface StatusBarProps {
|
|
||||||
isCollapsed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -44,112 +38,48 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isPending = startMutation.isPending || stopMutation.isPending;
|
const isPending = startMutation.isPending || stopMutation.isPending;
|
||||||
|
|
||||||
// Collapsed View
|
|
||||||
if (isCollapsed) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center py-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={handleToggle}
|
|
||||||
disabled={isLoading || isPending}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-200',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
isRunning
|
|
||||||
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
|
||||||
: 'bg-white/[0.04] text-muted-foreground hover:bg-white/[0.08] hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Power className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-1.5 w-1.5 rounded-full',
|
|
||||||
isRunning ? 'bg-emerald-400' : 'bg-muted-foreground',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{isLoading
|
|
||||||
? t('status.checking')
|
|
||||||
: isRunning
|
|
||||||
? t('status.running')
|
|
||||||
: t('status.stopped')}
|
|
||||||
</span>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded View
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center gap-2 rounded-md border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||||
className={cn(
|
{isLoading ? (
|
||||||
'flex items-center justify-between rounded-lg px-3 py-2 transition-all duration-200',
|
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
|
||||||
isRunning
|
) : (
|
||||||
? 'bg-emerald-500/[0.08]'
|
<div
|
||||||
: 'bg-white/[0.02]',
|
className={cn(
|
||||||
|
'h-2 w-2 rounded-full',
|
||||||
|
isRunning
|
||||||
|
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
|
||||||
|
: 'bg-muted-foreground/50',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
<span
|
||||||
<div className="flex items-center gap-2.5">
|
className={cn(
|
||||||
{isLoading ? (
|
'text-xs font-medium',
|
||||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-2 w-2 shrink-0 rounded-full transition-colors duration-300',
|
|
||||||
isRunning
|
|
||||||
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
|
|
||||||
: 'bg-muted-foreground/50',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
>
|
||||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
{isLoading ? '...' : isRunning ? 'Running' : 'Stopped'}
|
||||||
Status
|
</span>
|
||||||
</span>
|
|
||||||
<span className={cn(
|
|
||||||
'text-xs font-medium leading-tight',
|
|
||||||
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
|
|
||||||
)}>
|
|
||||||
{isLoading
|
|
||||||
? t('status.checking')
|
|
||||||
: isRunning
|
|
||||||
? t('status.running')
|
|
||||||
: t('status.stopped')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={isLoading || isPending}
|
disabled={isLoading || isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 gap-1.5 rounded-md px-2.5 text-xs',
|
'h-7 w-7 rounded-md p-0',
|
||||||
isRunning
|
isRunning
|
||||||
? 'text-emerald-400 hover:bg-emerald-500/10'
|
? 'text-emerald-400 hover:bg-emerald-500/10'
|
||||||
: 'text-muted-foreground hover:bg-white/[0.06]',
|
: 'text-muted-foreground hover:bg-white/[0.06]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : isRunning ? (
|
) : isRunning ? (
|
||||||
<Square className="h-3 w-3 fill-current" />
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
) : (
|
) : (
|
||||||
<Play className="h-3 w-3 fill-current" />
|
<Play className="h-3.5 w-3.5 fill-current" />
|
||||||
)}
|
)}
|
||||||
<span>{isRunning ? t('action.stop') : t('action.start')}</span>
|
<span className="sr-only">{isRunning ? t('action.stop') : t('action.start')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+67
-156
@@ -1,19 +1,10 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Link, Outlet, useLocation } from '@tanstack/react-router';
|
import { Link, Outlet, useLocation } from '@tanstack/react-router';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { StatusBar } from '@/components/StatusBar';
|
import { StatusBar } from '@/components/StatusBar';
|
||||||
import {
|
import { LayoutDashboard, Settings, Network, RefreshCw, BarChart3 } from 'lucide-react';
|
||||||
LayoutDashboard,
|
|
||||||
Settings,
|
|
||||||
Network,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
RefreshCw,
|
|
||||||
BarChart3,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||||
@@ -25,17 +16,6 @@ export const MainLayout: React.FC = () => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const hasShownRouteErrorToastRef = useRef(false);
|
const hasShownRouteErrorToastRef = useRef(false);
|
||||||
|
|
||||||
// Initialize state from localStorage if available, default to false (expanded)
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('sidebar-collapsed');
|
|
||||||
return saved ? JSON.parse(saved) : false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Persist state changes
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('sidebar-collapsed', JSON.stringify(isCollapsed));
|
|
||||||
}, [isCollapsed]);
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
to: '/',
|
to: '/',
|
||||||
@@ -61,146 +41,77 @@ export const MainLayout: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<header className="flex h-12 items-center border-b border-white/[0.06] px-4">
|
||||||
{/* Sidebar */}
|
<div className="flex shrink-0 items-center gap-2.5">
|
||||||
<aside
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
|
||||||
className={cn(
|
<img src={iconPng} alt="Gemini Nexus Logo" className="h-full w-full object-contain" />
|
||||||
'relative flex flex-col border-r border-white/[0.06] transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
</div>
|
||||||
isCollapsed ? 'w-[60px]' : 'w-56',
|
<h1 className="text-[15px] font-semibold tracking-tight whitespace-nowrap">
|
||||||
)}
|
Gemini Nexus
|
||||||
>
|
</h1>
|
||||||
{/* Collapse toggle */}
|
</div>
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'absolute -right-3 top-7 z-10 flex h-6 w-6 items-center justify-center rounded-full',
|
|
||||||
'bg-muted border border-white/[0.1] text-muted-foreground',
|
|
||||||
'opacity-0 transition-all duration-200 hover:bg-white/[0.1] hover:text-foreground',
|
|
||||||
'group-hover:opacity-100',
|
|
||||||
)}
|
|
||||||
style={{ opacity: 0 }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0')}
|
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Brand */}
|
<nav className="mx-6 flex flex-1 items-center justify-center gap-1 overflow-x-auto">
|
||||||
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-3' : 'px-4 py-5')}>
|
{navItems.map((item) => {
|
||||||
<div className="flex items-center gap-2.5 overflow-hidden whitespace-nowrap">
|
const isActive = location.pathname === item.to;
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
|
|
||||||
<img
|
return (
|
||||||
src={iconPng}
|
<Link
|
||||||
alt="Gemini Nexus Logo"
|
key={item.to}
|
||||||
className="h-full w-full object-contain"
|
to={item.to}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'overflow-hidden transition-all duration-300',
|
'flex items-center gap-2 rounded-md px-3 py-2 text-[13px] font-medium whitespace-nowrap transition-colors',
|
||||||
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
isActive
|
||||||
|
? 'text-foreground bg-white/[0.08]'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-white/[0.04]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h1 className="text-[15px] font-semibold tracking-tight">Gemini Nexus</h1>
|
<item.icon className="h-[16px] w-[16px]" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="shrink-0">
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<ErrorBoundary
|
||||||
|
resetKeys={[location.pathname]}
|
||||||
|
onReset={() => {
|
||||||
|
hasShownRouteErrorToastRef.current = false;
|
||||||
|
}}
|
||||||
|
onError={(error) => {
|
||||||
|
if (hasShownRouteErrorToastRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('error.generic'),
|
||||||
|
description: getLocalizedErrorMessage(error, t),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
hasShownRouteErrorToastRef.current = true;
|
||||||
|
}}
|
||||||
|
fallbackRender={({ resetErrorBoundary }) => (
|
||||||
|
<div className="mx-auto max-w-3xl p-6">
|
||||||
|
<div className="rounded-xl border border-dashed border-white/[0.06] p-8 text-center">
|
||||||
|
<div className="text-sm font-semibold">{t('error.generic')}</div>
|
||||||
|
<div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
|
||||||
|
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
{t('action.retry')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
>
|
||||||
{/* Navigation */}
|
<Outlet />
|
||||||
<nav className="flex-1 space-y-1 px-2 pt-2">
|
</ErrorBoundary>
|
||||||
<TooltipProvider>
|
</main>
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = location.pathname === item.to;
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
|
||||||
return (
|
|
||||||
<Tooltip key={item.to} delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Link
|
|
||||||
to={item.to}
|
|
||||||
className={cn(
|
|
||||||
'mx-auto flex h-9 w-9 items-center justify-center rounded-lg transition-all duration-200',
|
|
||||||
isActive
|
|
||||||
? 'bg-white/[0.08] text-foreground'
|
|
||||||
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="h-[18px] w-[18px]" />
|
|
||||||
<span className="sr-only">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="text-xs">
|
|
||||||
{item.label}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] font-medium transition-all duration-200',
|
|
||||||
isActive
|
|
||||||
? 'bg-white/[0.08] text-foreground'
|
|
||||||
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="h-[18px] w-[18px]" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TooltipProvider>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Status bar */}
|
|
||||||
<div className="border-t border-white/[0.06] p-2">
|
|
||||||
<StatusBar isCollapsed={isCollapsed} />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<main className="flex-1 overflow-auto transition-all duration-300">
|
|
||||||
<ErrorBoundary
|
|
||||||
resetKeys={[location.pathname]}
|
|
||||||
onReset={() => {
|
|
||||||
hasShownRouteErrorToastRef.current = false;
|
|
||||||
}}
|
|
||||||
onError={(error) => {
|
|
||||||
if (hasShownRouteErrorToastRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t('error.generic'),
|
|
||||||
description: getLocalizedErrorMessage(error, t),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
hasShownRouteErrorToastRef.current = true;
|
|
||||||
}}
|
|
||||||
fallbackRender={({ resetErrorBoundary }) => (
|
|
||||||
<div className="mx-auto max-w-3xl p-6">
|
|
||||||
<div className="rounded-xl border border-white/[0.06] border-dashed p-8 text-center">
|
|
||||||
<div className="text-sm font-semibold">{t('error.generic')}</div>
|
|
||||||
<div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
|
|
||||||
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
|
||||||
{t('action.retry')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl p-6">
|
<div className="container mx-auto max-w-6xl p-6">
|
||||||
<CloudAccountList />
|
<CloudAccountList />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user