mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat: add configurable navigation position (top/left/bottom/right) (#5945)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user