feat: add configurable navigation position (top/left/bottom/right) (#5945)

This commit is contained in:
Spence
2025-12-02 10:37:12 -05:00
committed by GitHub
parent 2863217f44
commit cc945effdd
4 changed files with 252 additions and 58 deletions
+100 -46
View File
@@ -1,6 +1,6 @@
import React, { useState, createContext, useContext, useCallback } from 'react';
import React, { useState, createContext, useContext, useCallback, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { AppWindowMac, AppWindow, ChevronDown, ChevronUp } from 'lucide-react';
import { AppWindowMac, AppWindow, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '../ui/button';
import { SidebarProvider } from '../ui/sidebar';
import { SidecarProvider, useSidecar } from '../SidecarLayout';
@@ -13,14 +13,17 @@ import DocumentEditor from '../DocumentEditor';
import WebViewer from '../WebViewer';
import { TopNavigation } from './TopNavigation';
import { NavigationPosition } from '../settings/app/NavigationPositionSelector';
// Create context for navigation state
const NavigationContext = createContext<{
isNavExpanded: boolean;
setIsNavExpanded: (expanded: boolean) => void;
navigationPosition: NavigationPosition;
}>({
isNavExpanded: false,
setIsNavExpanded: () => {}
setIsNavExpanded: () => {},
navigationPosition: 'top'
});
export const useNavigation = () => useContext(NavigationContext);
@@ -36,6 +39,21 @@ const AppLayoutContent: React.FC<AppLayoutProps> = ({ setIsGoosehintsModalOpen }
const safeIsMacOS = (window?.electron?.platform || 'darwin') === 'darwin';
const sidecar = useSidecar();
const [isNavExpanded, setIsNavExpanded] = useState(false);
const [navigationPosition, setNavigationPosition] = useState<NavigationPosition>(() => {
const stored = localStorage.getItem('navigation_position');
return (stored as NavigationPosition) || 'top';
});
// Listen for navigation position changes
useEffect(() => {
const handlePositionChange = (e: Event) => {
const customEvent = e as CustomEvent<{ position: NavigationPosition }>;
setNavigationPosition(customEvent.detail.position);
};
window.addEventListener('navigation-position-changed', handlePositionChange);
return () => window.removeEventListener('navigation-position-changed', handlePositionChange);
}, []);
// Bento box state management
const [bentoBoxContainers, setBentoBoxContainers] = useState<SidecarContainer[]>([]);
@@ -268,52 +286,82 @@ const AppLayoutContent: React.FC<AppLayoutProps> = ({ setIsGoosehintsModalOpen }
return () => window.removeEventListener('open-sidecar-localhost', handler);
}, [sidecar]);
// Determine layout direction based on navigation position
const isHorizontalNav = navigationPosition === 'top' || navigationPosition === 'bottom';
const flexDirection = isHorizontalNav ? 'flex-col' : 'flex-row';
// Render the main content area
const mainContent = (
<div className="flex-1 overflow-hidden">
<div className="h-full w-full bg-background-default rounded-2xl overflow-hidden">
{panels.length > 0 ? (
useMultiPanel && panels.length > 1 ? (
<MultiPanelSplitter
leftContent={<Outlet />}
panels={panels}
layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange}
onPanelResize={handlePanelResize}
onPanelReorder={handlePanelReorder}
initialLeftWidth={chatWidth}
className="h-full"
/>
) : (
<ResizableSplitter
leftContent={<Outlet />}
rightContent={
<EnhancedBentoBox
containers={bentoBoxContainers}
onRemoveContainer={handleRemoveFromBentoBox}
onAddContainer={handleAddToBentoBox}
onReorderContainers={handleReorderBentoBox}
/>
}
initialLeftWidth={chatWidth}
minLeftWidth={30}
maxLeftWidth={80}
onResize={setChatWidth}
className="h-full"
floatingRight={true}
/>
)
) : (
<Outlet />
)}
</div>
</div>
);
// Render navigation component
const navigationComponent = (
<TopNavigation
isExpanded={isNavExpanded}
setIsExpanded={setIsNavExpanded}
position={navigationPosition}
/>
);
return (
<NavigationContext.Provider value={{ isNavExpanded, setIsNavExpanded }}>
<div className="flex flex-col flex-1 w-full h-full bg-background-muted">
{/* Top Navigation Bar */}
<TopNavigation isExpanded={isNavExpanded} setIsExpanded={setIsNavExpanded} />
<NavigationContext.Provider value={{ isNavExpanded, setIsNavExpanded, navigationPosition }}>
<div className={`flex ${flexDirection} flex-1 w-full h-full bg-background-muted`}>
{/* Navigation placement based on position */}
{navigationPosition === 'top' && navigationComponent}
{navigationPosition === 'left' && navigationComponent}
{/* Main Content Area */}
<div className="flex-1 overflow-hidden">
{panels.length > 0 ? (
useMultiPanel && panels.length > 1 ? (
<MultiPanelSplitter
leftContent={<Outlet />}
panels={panels}
layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange}
onPanelResize={handlePanelResize}
onPanelReorder={handlePanelReorder}
initialLeftWidth={chatWidth}
className="h-full"
/>
) : (
<ResizableSplitter
leftContent={<Outlet />}
rightContent={
<EnhancedBentoBox
containers={bentoBoxContainers}
onRemoveContainer={handleRemoveFromBentoBox}
onAddContainer={handleAddToBentoBox}
onReorderContainers={handleReorderBentoBox}
/>
}
initialLeftWidth={chatWidth}
minLeftWidth={30}
maxLeftWidth={80}
onResize={setChatWidth}
className="h-full"
floatingRight={true}
/>
)
) : (
<Outlet />
)}
</div>
{mainContent}
{/* Control Buttons - floating in top right */}
<div className="absolute top-4 right-4 z-[9999] flex gap-2">
{/* Navigation placement for bottom and right */}
{navigationPosition === 'bottom' && navigationComponent}
{navigationPosition === 'right' && navigationComponent}
{/* Control Buttons - position based on nav location */}
<div className={`absolute z-[9999] flex gap-2 ${
navigationPosition === 'top' ? 'top-4 right-4' :
navigationPosition === 'bottom' ? 'bottom-4 right-4' :
navigationPosition === 'left' ? (safeIsMacOS ? 'top-4 left-20' : 'top-4 left-4') :
'top-4 right-4'
}`}>
<Button
onClick={() => setIsNavExpanded(!isNavExpanded)}
className="no-drag hover:!bg-background-medium bg-background-default rounded-xl shadow-sm relative"
@@ -321,7 +369,13 @@ const AppLayoutContent: React.FC<AppLayoutProps> = ({ setIsGoosehintsModalOpen }
size="xs"
title="Toggle navigation"
>
{isNavExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{navigationPosition === 'left' ? (
isNavExpanded ? <ChevronLeft className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />
) : navigationPosition === 'right' ? (
isNavExpanded ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />
) : (
isNavExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
<span className="ml-2 text-xs text-text-muted font-mono">
{isNavExpanded ? 'Hide menu' : 'Show menu'}
</span>
@@ -18,9 +18,12 @@ interface NavItem {
renderContent?: () => React.ReactNode;
}
export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right';
interface TopNavigationProps {
isExpanded: boolean;
setIsExpanded: (expanded: boolean) => void;
position?: NavigationPosition;
}
// Analog Clock Widget Component
@@ -108,7 +111,7 @@ const AnalogClock: React.FC = () => {
);
};
export const TopNavigation: React.FC<TopNavigationProps> = ({ isExpanded, setIsExpanded }) => {
export const TopNavigation: React.FC<TopNavigationProps> = ({ isExpanded, setIsExpanded, position = 'top' }) => {
const navigate = useNavigate();
const location = useLocation();
const { extensionsList, getExtensions } = useConfig();
@@ -458,23 +461,41 @@ export const TopNavigation: React.FC<TopNavigationProps> = ({ isExpanded, setIsE
return location.pathname === path;
};
// Determine grid layout based on position
const isVertical = position === 'left' || position === 'right';
// Calculate grid rows to fill vertical space
const totalItems = navItems.length;
const gridRows = Math.ceil(totalItems / 2); // 2 columns for vertical
const gridClasses = isVertical
? 'grid grid-cols-1 gap-0.5 h-full overflow-y-auto' // Vertical layout: 1 column, scrollable
: 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-6 gap-0.5'; // Horizontal layout: more columns
const containerClasses = isVertical
? 'h-full' // Full height for vertical nav
: 'w-full overflow-hidden'; // Full width for horizontal nav
return (
<div className="bg-background-muted overflow-hidden relative z-[9998]">
<div className={`bg-background-muted ${containerClasses} relative z-[9998]`}>
{/* Expanded Navigation Cards with Spring Animation */}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
initial={{
[isVertical ? 'width' : 'height']: 0,
opacity: 0
}}
animate={{
height: "auto",
[isVertical ? 'width' : 'height']: "auto",
opacity: 1,
}}
exit={{
height: 0,
[isVertical ? 'width' : 'height']: 0,
opacity: 0,
}}
transition={{
height: {
[isVertical ? 'width' : 'height']: {
type: "spring",
stiffness: 300,
damping: 30,
@@ -485,20 +506,28 @@ export const TopNavigation: React.FC<TopNavigationProps> = ({ isExpanded, setIsE
ease: "easeInOut"
}
}}
className="bg-background-muted overflow-hidden"
className={`bg-background-muted overflow-hidden ${isVertical ? 'h-full' : ''}`}
>
<motion.div
initial={{ y: -20 }}
animate={{ y: 0 }}
exit={{ y: -20 }}
initial={{ [isVertical ? 'x' : 'y']: -20 }}
animate={{ [isVertical ? 'x' : 'y']: 0 }}
exit={{ [isVertical ? 'x' : 'y']: -20 }}
transition={{
type: "spring",
stiffness: 400,
damping: 25,
}}
className="pb-0.5 overflow-y-auto lg:max-h-[2000px] md:max-h-[calc(100vh-60px)] max-h-screen"
className={`${isVertical ? 'p-1 h-full' : 'pb-0.5 lg:max-h-[2000px] md:max-h-[calc(100vh-60px)] max-h-screen'}`}
style={{ width: isVertical ? '360px' : undefined }}
>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-6 gap-0.5" style={{ gridTemplateColumns: isUltraWide ? 'repeat(12, minmax(0, 1fr))' : undefined }}>
<div
className={gridClasses}
style={{
gridTemplateColumns: !isVertical && isUltraWide ? 'repeat(12, minmax(0, 1fr))' : undefined,
gridTemplateRows: undefined,
width: isVertical ? 'auto' : undefined
}}
>
{navItems.map((item, index) => {
const isPulsing = pulsingItems.has(item.id);
const isDragging = draggedItem === item.id;
@@ -8,6 +8,7 @@ import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
import { getApiUrl } from '../../../config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
import ThemeSelector from '../../GooseSidebar/ThemeSelector';
import NavigationPositionSelector from './NavigationPositionSelector';
import BlockLogoBlack from './icons/block-lockup_black.png';
import BlockLogoWhite from './icons/block-lockup_white.png';
@@ -427,6 +428,16 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
</CardContent>
</Card>
<Card className="rounded-lg">
<CardHeader className="pb-0">
<CardTitle className="mb-1">Navigation Position</CardTitle>
<CardDescription>Choose where the navigation bar appears</CardDescription>
</CardHeader>
<CardContent className="pt-4 px-4">
<NavigationPositionSelector />
</CardContent>
</Card>
<Card className="rounded-lg">
<CardHeader className="pb-0">
<CardTitle className="mb-1">Background Image</CardTitle>
@@ -0,0 +1,100 @@
import { useState, useEffect } from 'react';
export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right';
const STORAGE_KEY = 'navigation_position';
export const useNavigationPosition = () => {
const [position, setPosition] = useState<NavigationPosition>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return (stored as NavigationPosition) || 'top';
});
const updatePosition = (newPosition: NavigationPosition) => {
setPosition(newPosition);
localStorage.setItem(STORAGE_KEY, newPosition);
// Dispatch custom event for AppLayout to listen to
window.dispatchEvent(new CustomEvent('navigation-position-changed', {
detail: { position: newPosition }
}));
};
return { position, updatePosition };
};
// Icon components for navigation positions
const TopNavIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="w-5 h-5">
{/* Small tiles on top */}
<rect x="3" y="3" width="4" height="4" rx="1.5" fill="currentColor" />
<rect x="10" y="3" width="4" height="4" rx="1.5" fill="currentColor" />
<rect x="17" y="3" width="4" height="4" rx="1.5" fill="currentColor" />
{/* Large tile below */}
<rect x="3" y="10" width="18" height="11" rx="2" fill="currentColor" opacity="0.3" />
</svg>
);
const BottomNavIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="w-5 h-5">
{/* Large tile on top */}
<rect x="3" y="3" width="18" height="11" rx="2" fill="currentColor" opacity="0.3" />
{/* Small tiles on bottom */}
<rect x="3" y="17" width="4" height="4" rx="1.5" fill="currentColor" />
<rect x="10" y="17" width="4" height="4" rx="1.5" fill="currentColor" />
<rect x="17" y="17" width="4" height="4" rx="1.5" fill="currentColor" />
</svg>
);
const LeftNavIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="w-5 h-5">
{/* Small tiles on left */}
<rect x="3" y="3" width="4" height="4" rx="1.5" fill="currentColor" />
<rect x="3" y="10" width="4" height="4" rx="1.5" fill="currentColor" />
<rect x="3" y="17" width="4" height="4" rx="1.5" fill="currentColor" />
{/* Large tile on right */}
<rect x="10" y="3" width="11" height="18" rx="2" fill="currentColor" opacity="0.3" />
</svg>
);
const RightNavIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="w-5 h-5">
{/* Large tile on left */}
<rect x="3" y="3" width="11" height="18" rx="2" fill="currentColor" opacity="0.3" />
{/* Small tiles on right */}
<rect x="17" y="3" width="4" height="4" rx="1.5" fill="currentColor" />
<rect x="17" y="10" width="4" height="4" rx="1.5" fill="currentColor" />
<rect x="17" y="17" width="4" height="4" rx="1.5" fill="currentColor" />
</svg>
);
export default function NavigationPositionSelector() {
const { position, updatePosition } = useNavigationPosition();
const positions: Array<{ value: NavigationPosition; label: string; icon: React.ReactNode }> = [
{ value: 'top', label: 'Top', icon: <TopNavIcon /> },
{ value: 'left', label: 'Left', icon: <LeftNavIcon /> },
{ value: 'bottom', label: 'Bottom', icon: <BottomNavIcon /> },
{ value: 'right', label: 'Right', icon: <RightNavIcon /> },
];
return (
<div className="grid grid-cols-4 gap-2">
{positions.map((pos) => (
<button
key={pos.value}
onClick={() => updatePosition(pos.value)}
className={`
flex flex-col items-center justify-center gap-2 p-4 rounded-lg border-2 transition-all
${position === pos.value
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'border-border-default hover:border-border-subtle hover:bg-background-subtle'
}
`}
>
{pos.icon}
<span className="text-xs font-medium">{pos.label}</span>
</button>
))}
</div>
);
}