feat: Complete navigation system with customizable layouts and widgets

Add comprehensive navigation system with two modes (push/overlay), four positions,
three interactive widgets, and full customization capabilities.

Components:
- TopNavigation: Expanded tile-based layout with auto-fit grid
- CondensedNavigation: Compact row-based layout
- NavigationCustomizationSettings: Preferences management
- Navigation mode/position/style selectors

Features:
- Two navigation modes: push (sidebar) and overlay (launcher)
- Four positions: top, bottom, left, right
- Two styles: expanded (tiles) and condensed (rows)
- Three widgets: analog clock, activity heatmap, token counter
- Drag & drop reordering of navigation items
- Show/hide controls for navigation items
- Comprehensive settings UI with collapsible sections
- Persistent user preferences in localStorage
- Settings tab state persistence across mode changes
- Centered overlay mode (always expanded)
- Real-time stats and live data updates

Technical:
- Added custom CSS animations to main.css (nav-tile, nav-overlay-enter, etc.)
- Components use framer-motion for animations (needs manual refactor to CSS)
- All TypeScript types properly defined
- Settings integration complete

Note: The navigation components currently import framer-motion for animations.
These imports need to be manually replaced with the CSS animations prepared
in main.css. Automated refactoring broke JSX structure in testing.

TODO: Replace framer-motion imports with CSS-based animations before merge.
This commit is contained in:
spencrmartin
2026-01-13 13:38:45 -05:00
parent d9a30a8f99
commit dcf94c6abe
10 changed files with 2467 additions and 94 deletions
+254 -92
View File
@@ -1,105 +1,267 @@
import React from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import AppSidebar from '../GooseSidebar/AppSidebar';
import { View, ViewOptions } from '../../utils/navigationUtils';
import { AppWindowMac, AppWindow } from 'lucide-react';
import React, { useState, createContext, useContext, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { AppWindowMac, AppWindow, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '../ui/button';
import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar';
import { getInitialWorkingDir } from '../../utils/workingDir';
import { SidebarProvider } from '../ui/sidebar';
import { TopNavigation } from './TopNavigation';
import { CondensedNavigation } from './CondensedNavigation';
import { NavigationPosition } from '../settings/app/NavigationPositionSelector';
import { NavigationStyle } from '../settings/app/NavigationStyleSelector';
import { NavigationMode } from '../settings/app/NavigationModeSelector';
const AppLayoutContent: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
// Create context for navigation state
const NavigationContext = createContext<{
isNavExpanded: boolean;
setIsNavExpanded: (expanded: boolean) => void;
navigationPosition: NavigationPosition;
}>({
isNavExpanded: false,
setIsNavExpanded: () => {},
navigationPosition: 'top'
});
export const useNavigation = () => useContext(NavigationContext);
interface AppLayoutProps {
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
}
// Inner component
const AppLayoutContent: React.FC<AppLayoutProps> = () => {
const safeIsMacOS = (window?.electron?.platform || 'darwin') === 'darwin';
const { isMobile, openMobile } = useSidebar();
// Calculate padding based on sidebar state and macOS
const headerPadding = safeIsMacOS ? 'pl-21' : 'pl-4';
// const headerPadding = '';
// Hide buttons when mobile sheet is showing
const shouldHideButtons = isMobile && openMobile;
const setView = (view: View, viewOptions?: ViewOptions) => {
// Convert view-based navigation to route-based navigation
switch (view) {
case 'chat':
navigate('/');
break;
case 'pair':
navigate('/pair');
break;
case 'settings':
navigate('/settings', { state: viewOptions });
break;
case 'extensions':
navigate('/extensions', { state: viewOptions });
break;
case 'sessions':
navigate('/sessions');
break;
case 'schedules':
navigate('/schedules');
break;
case 'recipes':
navigate('/recipes');
break;
case 'permission':
navigate('/permission', { state: viewOptions });
break;
case 'ConfigureProviders':
navigate('/configure-providers');
break;
case 'sharedSession':
navigate('/shared-session', { state: viewOptions });
break;
case 'welcome':
navigate('/welcome');
break;
default:
navigate('/');
}
};
const handleSelectSession = async (sessionId: string) => {
// Navigate to chat with session data
navigate('/', { state: { sessionId } });
};
const [isNavExpanded, setIsNavExpanded] = useState(false);
const [navigationPosition, setNavigationPosition] = useState<NavigationPosition>(() => {
const stored = localStorage.getItem('navigation_position');
return (stored as NavigationPosition) || 'top';
});
const [navigationStyle, setNavigationStyle] = useState<NavigationStyle>(() => {
const stored = localStorage.getItem('navigation_style');
return (stored as NavigationStyle) || 'expanded';
});
const [navigationMode, setNavigationMode] = useState<NavigationMode>(() => {
const stored = localStorage.getItem('navigation_mode');
return (stored as NavigationMode) || 'push';
});
// 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);
}, []);
// Listen for navigation style changes
useEffect(() => {
const handleStyleChange = (e: Event) => {
const customEvent = e as CustomEvent<{ style: NavigationStyle }>;
setNavigationStyle(customEvent.detail.style);
};
window.addEventListener('navigation-style-changed', handleStyleChange);
return () => window.removeEventListener('navigation-style-changed', handleStyleChange);
}, []);
// Listen for navigation mode changes
useEffect(() => {
const handleModeChange = (e: Event) => {
const customEvent = e as CustomEvent<{ mode: NavigationMode }>;
setNavigationMode(customEvent.detail.mode);
};
window.addEventListener('navigation-mode-changed', handleModeChange);
return () => window.removeEventListener('navigation-mode-changed', handleModeChange);
}, []);
const handleNewWindow = () => {
window.electron.createChatWindow(undefined, getInitialWorkingDir());
window.electron.createChatWindow(
undefined,
window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined
);
};
return (
<div className="flex flex-1 w-full relative animate-fade-in">
{!shouldHideButtons && (
<div className={`${headerPadding} absolute top-3 z-100 flex items-center`}>
<SidebarTrigger
className={`no-drag hover:border-border-strong hover:text-text-default hover:!bg-background-medium hover:scale-105`}
/>
<Button
onClick={handleNewWindow}
className="no-drag hover:!bg-background-medium"
variant="ghost"
size="xs"
title="Start a new session in a new window"
>
{safeIsMacOS ? <AppWindowMac className="w-4 h-4" /> : <AppWindow className="w-4 h-4" />}
</Button>
</div>
)}
<Sidebar variant="inset" collapsible="offcanvas">
<AppSidebar
onSelectSession={handleSelectSession}
setView={setView}
currentPath={location.pathname}
/>
</Sidebar>
<SidebarInset>
// Determine layout direction based on navigation position (only for push mode)
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">
<Outlet />
</SidebarInset>
</div>
</div>
);
// Render navigation component based on style
const navigationComponent = navigationStyle === 'expanded' ? (
<TopNavigation
isExpanded={isNavExpanded}
setIsExpanded={setIsNavExpanded}
position={navigationPosition}
isOverlayMode={navigationMode === 'overlay'}
/>
) : (
<CondensedNavigation
isExpanded={isNavExpanded}
setIsExpanded={setIsNavExpanded}
position={navigationPosition}
isOverlayMode={navigationMode === 'overlay'}
/>
);
// Overlay navigation component (full screen)
const overlayNavigationComponent = (
<div className={`
fixed inset-0 z-[10000] pointer-events-none
${isNavExpanded ? 'pointer-events-auto' : ''}
`}>
{/* Overlay background with blur - click to close */}
{isNavExpanded && (
<div
className="absolute inset-0 bg-black/20 backdrop-blur-md"
onClick={() => setIsNavExpanded(false)}
/>
)}
{/* Navigation overlay - Full screen without container */}
<div className={`
absolute inset-0
transition-all duration-300 ease-out pointer-events-auto
${isNavExpanded
? 'opacity-100 scale-100'
: 'opacity-0 scale-95 pointer-events-none'
}
`}>
{/* Navigation content - let TopNavigation handle positioning */}
{navigationComponent}
</div>
</div>
);
return (
<NavigationContext.Provider value={{ isNavExpanded, setIsNavExpanded, navigationPosition }}>
<div className="flex flex-col flex-1 w-full h-full bg-background-muted">
{navigationMode === 'overlay' ? (
// Overlay Mode - Full screen content with floating navigation
<div className="flex flex-1 w-full h-full bg-background-muted relative">
{/* Main Content Area - Full Screen */}
{mainContent}
{/* Overlay Navigation - Only show when expanded */}
{overlayNavigationComponent}
{/* Control Buttons - Position based on navigation position setting */}
<div className={`absolute z-[10002] 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"
variant="ghost"
size="xs"
title="Toggle navigation overlay"
>
{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 launcher' : 'Show launcher'}
</span>
</Button>
<Button
onClick={handleNewWindow}
className="no-drag hover:!bg-background-medium bg-background-default rounded-xl shadow-sm"
variant="ghost"
size="xs"
title="Start a new session in a new window"
>
{safeIsMacOS ? <AppWindowMac className="w-4 h-4" /> : <AppWindow className="w-4 h-4" />}
</Button>
</div>
</div>
) : (
// Push Mode - Traditional layout with positioned navigation
<div className={`flex ${flexDirection} flex-1 w-full h-full bg-background-muted`}>
{/* Navigation placement based on position - always render but let component handle visibility */}
{navigationPosition === 'top' && (
<div className="flex flex-col">
{navigationComponent}
</div>
)}
{navigationPosition === 'left' && (
<div className="flex flex-col">
{navigationComponent}
</div>
)}
{/* Main Content Area */}
{mainContent}
{/* Navigation placement for bottom and right - always render but let component handle visibility */}
{navigationPosition === 'bottom' && (
<div className="flex flex-col">
{navigationComponent}
</div>
)}
{navigationPosition === 'right' && (
<div className="flex flex-col">
{navigationComponent}
</div>
)}
{/* Control Buttons - position based on nav location */}
<div className={`absolute z-[10002] 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"
variant="ghost"
size="xs"
title="Toggle navigation"
>
{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>
</Button>
<Button
onClick={handleNewWindow}
className="no-drag hover:!bg-background-medium bg-background-default rounded-xl shadow-sm"
variant="ghost"
size="xs"
title="Start a new session in a new window"
>
{safeIsMacOS ? <AppWindowMac className="w-4 h-4" /> : <AppWindow className="w-4 h-4" />}
</Button>
</div>
</div>
)}
</div>
</NavigationContext.Provider>
);
};
export const AppLayout: React.FC = () => {
@@ -0,0 +1,885 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Home, History, FileText, Puzzle, Settings as SettingsIcon, GripVertical, Users, Hash, ShoppingBag } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChatSmart } from '../icons';
import { listSessions, getSessionInsights } from '../../api';
import { useConfig } from '../ConfigContext';
import { listSavedRecipes } from '../../recipe/recipe_management';
import { useNavigationCustomization } from '../settings/app/NavigationCustomizationSettings';
interface NavItem {
id: string;
path?: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
getTag?: () => string;
tagAlign?: 'left' | 'right';
isWidget?: boolean;
renderContent?: () => React.ReactNode;
}
export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right';
interface CondensedNavigationProps {
isExpanded: boolean;
setIsExpanded: (expanded: boolean) => void;
position?: NavigationPosition;
isOverlayMode?: boolean;
}
export const CondensedNavigation: React.FC<CondensedNavigationProps> = ({
isExpanded,
setIsExpanded,
position = 'top',
isOverlayMode = false
}) => {
const navigate = useNavigate();
const location = useLocation();
const { extensionsList, getExtensions } = useConfig();
const { preferences } = useNavigationCustomization();
const [forceUpdate, setForceUpdate] = useState(0);
const [currentTime, setCurrentTime] = useState('');
const [todayChatsCount, setTodayChatsCount] = useState(0);
const [totalSessions, setTotalSessions] = useState(0);
const [recipesCount, setRecipesCount] = useState(0);
const [scheduledTodayCount, setScheduledTodayCount] = useState(0);
const [totalTokens, setTotalTokens] = useState(0);
// Handle escape key to close overlay
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isExpanded && isOverlayMode) {
event.preventDefault();
event.stopPropagation();
setIsExpanded(false);
}
};
if (isOverlayMode && isExpanded) {
document.addEventListener('keydown', handleKeyDown, { capture: true });
return () => document.removeEventListener('keydown', handleKeyDown, { capture: true });
}
return undefined;
}, [isExpanded, isOverlayMode, setIsExpanded]);
const [sessionHeatmapData, setSessionHeatmapData] = useState<Record<string, number>>({});
// Track previous values for pulse animation
const [prevValues, setPrevValues] = useState<Record<string, string>>({});
const [pulsingItems, setPulsingItems] = useState<Set<string>>(new Set());
// Drag and drop state
const [draggedItem, setDraggedItem] = useState<string | null>(null);
const [dragOverItem, setDragOverItem] = useState<string | null>(null);
const [itemOrder, setItemOrder] = useState<string[]>([]);
// Update time every second
useEffect(() => {
const updateTime = () => {
const now = new Date();
setCurrentTime(now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }));
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, []);
// Fetch data when navigation expands
useEffect(() => {
if (isExpanded) {
fetchNavigationData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isExpanded]);
const fetchNavigationData = async () => {
try {
// Fetch today's chats and build heatmap
const sessionsResponse = await listSessions({ throwOnError: false });
if (sessionsResponse.data) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Build heatmap data for last 30 days and count today's chats in one pass
const heatmap: Record<string, number> = {};
let todayCount = 0;
sessionsResponse.data.sessions.forEach((session) => {
const sessionDate = new Date(session.created_at);
const dateKey = sessionDate.toISOString().split('T')[0];
heatmap[dateKey] = (heatmap[dateKey] || 0) + 1;
// Count today's chats
const sessionDateOnly = new Date(session.created_at);
sessionDateOnly.setHours(0, 0, 0, 0);
if (sessionDateOnly.getTime() === today.getTime()) {
todayCount++;
}
});
setSessionHeatmapData(heatmap);
setTodayChatsCount(todayCount);
}
// Fetch total sessions and tokens
const insightsResponse = await getSessionInsights({ throwOnError: false });
if (insightsResponse.data) {
setTotalSessions(insightsResponse.data.totalSessions || 0);
setTotalTokens(insightsResponse.data.totalTokens || 0);
}
// Fetch recipes count
try {
const recipes = await listSavedRecipes();
setRecipesCount(recipes.length);
} catch (error) {
console.error('Failed to fetch recipes:', error);
setRecipesCount(0);
}
// TODO: Fetch scheduled jobs when API is available
setScheduledTodayCount(0);
// Refresh extensions data
try {
await getExtensions(true);
} catch (error) {
console.error('Failed to fetch extensions:', error);
}
} catch (error) {
console.error('Failed to fetch navigation data:', error);
}
};
// Analog Clock Widget Component
const AnalogClock: React.FC = () => {
const [secondAngle, setSecondAngle] = useState(() => {
const now = new Date();
return now.getSeconds() * 6;
});
const [angles, setAngles] = useState(() => {
const now = new Date();
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
return {
hour: (hours * 30) + (minutes * 0.5),
minute: minutes * 6,
};
});
useEffect(() => {
const interval = setInterval(() => {
const now = new Date();
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
// Increment second angle by 6 degrees each second
setSecondAngle(prev => prev + 6);
setAngles({
hour: (hours * 30) + (minutes * 0.5),
minute: minutes * 6,
});
}, 1000);
return () => clearInterval(interval);
}, []);
const hourAngle = angles.hour;
const minuteAngle = angles.minute;
return (
<div className="w-full h-full flex items-center justify-center">
<div className="relative w-24 h-24">
{/* Hour markers */}
{[...Array(12)].map((_, i) => (
<div
key={i}
className="absolute w-0.5 h-2 bg-text-muted/40 top-2 left-1/2 -translate-x-1/2"
style={{
transformOrigin: '50% 46px',
transform: `translateX(-50%) rotate(${i * 30}deg)`,
}}
/>
))}
{/* Hour hand */}
<div
className="absolute w-1.5 h-9 bg-text-default rounded-full top-1/2 left-1/2 -translate-x-1/2 origin-bottom transition-transform duration-1000 ease-linear"
style={{
transform: `translateX(-50%) translateY(-100%) rotate(${hourAngle}deg)`,
}}
/>
{/* Minute hand */}
<div
className="absolute w-1 h-11 bg-text-default rounded-full top-1/2 left-1/2 -translate-x-1/2 origin-bottom transition-transform duration-1000 ease-linear"
style={{
transform: `translateX(-50%) translateY(-100%) rotate(${minuteAngle}deg)`,
}}
/>
{/* Second hand */}
<div
className="absolute w-0.5 h-12 bg-red-500 rounded-full top-1/2 left-1/2 -translate-x-1/2 origin-bottom transition-transform duration-1000 ease-linear"
style={{
transform: `translateX(-50%) translateY(-100%) rotate(${secondAngle}deg)`,
}}
/>
{/* Center dot */}
<div className="absolute w-2 h-2 bg-text-default rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
</div>
);
};
const navItemsBase: NavItem[] = [
{
id: 'home',
path: '/',
label: 'Home',
icon: Home,
getTag: () => currentTime,
},
{
id: 'chat',
path: '/pair',
label: 'Chat',
icon: ChatSmart,
getTag: () => `${todayChatsCount}`,
tagAlign: 'left',
},
{
id: 'history',
path: '/sessions',
label: 'History',
icon: History,
getTag: () => `${totalSessions}`,
tagAlign: 'left',
},
{
id: 'recipes',
path: '/recipes',
label: 'Recipes',
icon: FileText,
getTag: () => `${recipesCount}`,
},
{
id: 'scheduler',
path: '/schedules',
label: 'Marketplace',
icon: ShoppingBag,
},
{
id: 'extensions',
path: '/extensions',
label: 'Extensions',
icon: Puzzle,
getTag: () => {
if (!extensionsList || !Array.isArray(extensionsList)) {
return '0/0';
}
const enabled = extensionsList.filter(ext => ext.enabled).length;
const total = extensionsList.length;
return `${enabled}/${total}`;
},
},
{
id: 'peers',
path: '/peers',
label: 'Peers',
icon: Users,
getTag: () => '3',
tagAlign: 'left',
},
{
id: 'channels',
path: '/channels',
label: 'Channels',
icon: Hash,
getTag: () => '7',
tagAlign: 'left',
},
{
id: 'settings',
path: '/settings',
label: 'Settings',
icon: SettingsIcon,
},
// Widget tiles for overlay mode
{
id: 'clock-widget',
label: 'Clock',
isWidget: true,
renderContent: () => <AnalogClock />,
},
{
id: 'activity-widget',
label: 'Activity',
isWidget: true,
renderContent: () => {
const days = 35;
const today = new Date();
const heatmapCells = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateKey = date.toISOString().split('T')[0];
const count = sessionHeatmapData[dateKey] || 0;
const maxCount = Math.max(...Object.values(sessionHeatmapData), 1);
const intensity = count === 0 ? 0 : Math.ceil((count / maxCount) * 4);
heatmapCells.push({ date: dateKey, count, intensity });
}
return (
<div className="w-full h-full flex flex-col items-center justify-center p-4">
<div className="grid grid-cols-7 gap-1">
{heatmapCells.map((cell, index) => (
<div
key={index}
className={`w-3 h-3 rounded-sm ${
cell.intensity === 0 ? 'bg-background-muted' :
cell.intensity === 1 ? 'bg-green-200 dark:bg-green-900' :
cell.intensity === 2 ? 'bg-green-300 dark:bg-green-700' :
cell.intensity === 3 ? 'bg-green-400 dark:bg-green-600' :
'bg-green-500 dark:bg-green-500'
}`}
title={`${cell.date}: ${cell.count} sessions`}
/>
))}
</div>
<div className="mt-3 text-xs text-text-muted font-mono">
Last 35 days
</div>
</div>
);
},
},
{
id: 'tokens-widget',
label: 'Tokens',
isWidget: true,
renderContent: () => {
const tokensInMillions = (totalTokens / 1000000).toFixed(2);
return (
<div className="w-full h-full flex flex-col items-center justify-center p-6">
<div className="text-center">
<div className="text-3xl font-mono font-light text-text-default mb-2">
{tokensInMillions}M
</div>
<div className="text-xs text-text-muted font-mono">
Total tokens
</div>
</div>
</div>
);
},
},
];
// Filter and order navigation items based on user preferences
const navItems = React.useMemo(() => {
console.log('CondensedNavigation: Recomputing navItems with preferences:', preferences);
// Filter enabled items
const enabledItems = navItemsBase.filter(item =>
preferences.enabledItems.includes(item.id)
);
// Filter widgets based on overlay mode
const filteredItems = enabledItems.filter(item =>
isOverlayMode || !item.isWidget
);
// Order items according to user preferences
const orderedItems = preferences.itemOrder
.map(id => filteredItems.find(item => item.id === id))
.filter(Boolean) as NavItem[];
// Add any new items that aren't in the order yet (for backwards compatibility)
const itemsNotInOrder = filteredItems.filter(item =>
!preferences.itemOrder.includes(item.id)
);
const result = [...orderedItems, ...itemsNotInOrder];
console.log('CondensedNavigation: Computed navItems:', result.map(item => item.id));
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItemsBase, preferences.enabledItems, preferences.itemOrder, forceUpdate, isOverlayMode, currentTime, todayChatsCount, totalSessions, recipesCount, totalTokens]);
// Listen for navigation preferences updates
useEffect(() => {
const handlePreferencesUpdate = (event: Event) => {
const customEvent = event as CustomEvent;
console.log('CondensedNavigation: Navigation preferences updated:', customEvent.detail);
// Force re-render when preferences change by updating multiple states
setForceUpdate(prev => prev + 1);
setItemOrder(prev => [...prev]);
};
window.addEventListener('navigation-preferences-updated', handlePreferencesUpdate);
return () => {
window.removeEventListener('navigation-preferences-updated', handlePreferencesUpdate);
};
}, []);
// Track value changes for pulse animation
useEffect(() => {
const currentValues: Record<string, string> = {
chat: `${todayChatsCount}`,
history: `${totalSessions}`,
recipes: `${recipesCount}`,
scheduler: `${scheduledTodayCount}`,
tokens: `${totalTokens}`,
};
Object.entries(currentValues).forEach(([key, value]) => {
if (prevValues[key] && prevValues[key] !== value) {
setPulsingItems(prev => new Set(prev).add(key));
setTimeout(() => {
setPulsingItems(prev => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 2000);
}
});
setPrevValues(currentValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [todayChatsCount, totalSessions, recipesCount, scheduledTodayCount, totalTokens]);
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, itemId: string) => {
setDraggedItem(itemId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, itemId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedItem && draggedItem !== itemId) {
setDragOverItem(itemId);
}
};
const handleDrop = (e: React.DragEvent, dropItemId: string) => {
e.preventDefault();
if (!draggedItem || draggedItem === dropItemId) return;
const newOrder = [...itemOrder];
const draggedIndex = newOrder.indexOf(draggedItem);
const dropIndex = newOrder.indexOf(dropItemId);
newOrder.splice(draggedIndex, 1);
newOrder.splice(dropIndex, 0, draggedItem);
setItemOrder(newOrder);
setDraggedItem(null);
setDragOverItem(null);
};
const handleDragEnd = () => {
setDraggedItem(null);
setDragOverItem(null);
};
const isActive = (path: string) => {
return location.pathname === path;
};
// Determine layout based on position
const isVertical = position === 'left' || position === 'right';
// Position-aware container classes for overlay mode (matches TopNavigation)
const containerClasses = isOverlayMode
? `w-full h-full flex ${
position === 'top' ? 'items-start justify-center' :
position === 'bottom' ? 'items-end justify-center' :
position === 'left' ? 'items-center justify-start' :
'items-center justify-end'
}`
: isVertical
? 'h-full'
: 'w-full overflow-hidden';
return (
<div className={`${isOverlayMode ? 'bg-transparent' : 'bg-background-muted'} ${containerClasses} relative z-[9998]`}>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={isOverlayMode ? {
opacity: 0,
scale: 0.95
} : {
[isVertical ? 'width' : 'height']: 0,
opacity: 0
}}
animate={isOverlayMode ? {
opacity: 1,
scale: 1
} : {
[isVertical ? 'width' : 'height']: "auto",
opacity: 1,
}}
exit={isOverlayMode ? {
opacity: 0,
scale: 0.95
} : {
[isVertical ? 'width' : 'height']: 0,
opacity: 0,
}}
transition={isOverlayMode ? {
duration: 0.3,
ease: "easeOut"
} : {
[isVertical ? 'width' : 'height']: {
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.8,
},
opacity: {
duration: 0.3,
ease: "easeInOut"
}
}}
className={`${isOverlayMode ? 'bg-transparent' : 'bg-background-muted overflow-hidden'} ${
!isOverlayMode && isVertical ? 'h-full lg:relative lg:z-auto absolute z-[10000] top-0 shadow-lg lg:shadow-none' : ''
} ${
!isOverlayMode && isVertical && position === 'left' ? 'left-0' : ''
} ${
!isOverlayMode && isVertical && position === 'right' ? 'right-0' : ''
}`}
>
<motion.div
initial={isOverlayMode ? { opacity: 0 } : { [isVertical ? 'x' : 'y']: -20 }}
animate={isOverlayMode ? { opacity: 1 } : { [isVertical ? 'x' : 'y']: 0 }}
exit={isOverlayMode ? { opacity: 0 } : { [isVertical ? 'x' : 'y']: -20 }}
transition={isOverlayMode ? {
duration: 0.2,
delay: 0.1
} : {
type: "spring",
stiffness: 400,
damping: 25,
}}
className={`${isOverlayMode ? '' : ''} ${
!isOverlayMode && isVertical
? position === 'right'
? 'h-full pl-0.5'
: 'h-full pr-0.5'
: !isOverlayMode && position === 'top'
? 'pr-0.5 pb-0.5'
: !isOverlayMode ? 'pr-0.5' : ''
}`}
>
{isOverlayMode ? (
// Overlay Mode: Independent menu container and widget tiles
<div className={`flex flex-row gap-[2px] ${
position === 'left' || position === 'right' ? 'items-center' :
position === 'bottom' ? 'items-end' : 'items-start'
} ${
position === 'top' ? 'pt-[2px]' :
position === 'bottom' ? 'pb-[2px]' :
position === 'left' ? 'pl-[2px]' :
'pr-[2px]'
}`}>
{/* Menu Container: Windows Start Menu style */}
<div
className="bg-background-card backdrop-blur-xl rounded-2xl shadow-2xl border border-border-default overflow-hidden"
style={{ width: '320px', maxHeight: '500px' }}
>
{/* Navigation Rows */}
<div className="flex flex-col gap-[1px] p-4">
{navItems.filter(item => !item.isWidget).map((item, index) => {
const isPulsing = pulsingItems.has(item.id);
const isDragging = draggedItem === item.id;
const isDragOver = dragOverItem === item.id;
const IconComponent = item.icon;
const active = item.path ? isActive(item.path) : false;
return (
<motion.div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent, item.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)}
onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)}
onDragEnd={handleDragEnd}
initial={{ opacity: 0, x: -20, scale: 0.9 }}
animate={{
opacity: 1,
x: 0,
scale: isDragging ? 0.95 : 1,
}}
transition={{
type: "spring",
stiffness: 350,
damping: 25,
delay: index * 0.02,
}}
className={`
relative cursor-move group w-full
${isDragOver ? 'ring-2 ring-blue-500 rounded-lg' : ''}
`}
style={{
opacity: isDragging ? 0.5 : 1,
}}
>
<motion.button
onClick={() => {
if (item.path) {
navigate(item.path);
setIsExpanded(false);
}
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`
flex flex-row items-center gap-2 w-full
relative rounded-lg transition-colors duration-200 no-drag
pl-2 pr-4 py-2.5
${active
? 'bg-background-accent text-text-on-accent backdrop-blur-md'
: 'bg-background-default text-text-default hover:bg-background-medium backdrop-blur-md'
}
`}
>
{/* Drag handle indicator */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<GripVertical className="w-4 h-4 text-text-muted" />
</div>
{/* Icon */}
{IconComponent && <IconComponent className="w-5 h-5 flex-shrink-0" />}
{/* Label */}
<span className="text-sm font-medium flex-1 text-left">{item.label}</span>
{/* Tag/Badge */}
{item.getTag && (
<div className="flex items-center gap-1">
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
active
? 'bg-background-default/20 text-text-on-accent/80 backdrop-blur-sm'
: 'bg-background-muted text-text-muted backdrop-blur-sm'
}`}>
{item.getTag()}
</span>
</div>
)}
{/* Update indicator dot */}
{isPulsing && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse"
/>
)}
</motion.button>
</motion.div>
);
})}
</div>
</div>
{/* Widget Tiles: 2x2 Grid - Height matches menu container */}
<div
className="grid grid-cols-2 gap-[2px]"
style={{ maxHeight: '500px' }}
>
{(() => {
const widgets = navItems.filter(item => item.isWidget);
// For bottom position, reorder to L-shape: [0,1,2] -> [0,undefined,1,2]
// This creates: Top row: Clock (0), undefined | Bottom row: Activity (1), Tokens (2)
if (position === 'bottom' && widgets.length >= 3) {
return [widgets[0], undefined, widgets[1], widgets[2]];
}
return widgets;
})().map((item, index) => {
if (!item) {
// Render empty placeholder for undefined slots
return (
<div
key={`empty-${index}`}
style={{
width: 'calc((500px - 2px) / 2)',
height: 'calc((500px - 2px) / 2)',
}}
/>
);
}
const isDragging = draggedItem === item.id;
const isDragOver = dragOverItem === item.id;
return (
<motion.div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent, item.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)}
onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)}
onDragEnd={handleDragEnd}
initial={{ opacity: 0, x: 20, scale: 0.9 }}
animate={{
opacity: 1,
x: 0,
scale: isDragging ? 0.95 : 1,
}}
transition={{
type: "spring",
stiffness: 350,
damping: 25,
delay: (index + 9) * 0.02, // Delay after navigation items
}}
className={`
relative cursor-move group
${isDragOver ? 'ring-2 ring-blue-500 rounded-2xl' : ''}
`}
style={{
opacity: isDragging ? 0.5 : 1,
width: 'calc((500px - 2px) / 2)', // Match height for square tiles
height: 'calc((500px - 2px) / 2)', // (maxHeight - gap) / 2 rows
}}
>
<div className="w-full h-full bg-background-card backdrop-blur-xl rounded-2xl shadow-2xl border border-border-default overflow-hidden relative group">
{/* Drag handle indicator */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<GripVertical className="w-4 h-4 text-text-muted" />
</div>
{item.renderContent && item.renderContent()}
</div>
</motion.div>
);
})}
</div>
</div>
) : (
// Push Mode: Original single-column layout
<div className={`${isVertical ? 'flex flex-col w-full gap-[1px] h-full' : 'flex flex-row w-full gap-[1px]'}`}>
{/* Top spacer for vertical layout */}
{(isVertical || position === 'top') && (
<div className={`${isVertical ? 'h-[60px] w-full' : 'w-[100px]'} bg-background-default rounded-lg flex items-center justify-center py-2.5`} />
)}
{navItems.filter(item => !item.isWidget).map((item, index) => {
const isPulsing = pulsingItems.has(item.id);
const isDragging = draggedItem === item.id;
const isDragOver = dragOverItem === item.id;
const IconComponent = item.icon;
const active = item.path ? isActive(item.path) : false;
return (
<motion.div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent, item.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)}
onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)}
onDragEnd={handleDragEnd}
initial={{ opacity: 0, [isVertical ? 'x' : 'y']: 20, scale: 0.9 }}
animate={{
opacity: 1,
[isVertical ? 'x' : 'y']: 0,
scale: isDragging ? 0.95 : 1,
}}
transition={{
type: "spring",
stiffness: 350,
damping: 25,
delay: index * 0.02,
}}
className={`
relative cursor-move group
${isVertical ? 'w-full' : 'flex-1'}
${isDragOver ? 'ring-2 ring-blue-500 rounded-lg' : ''}
`}
style={{
opacity: isDragging ? 0.5 : 1,
}}
>
<motion.button
onClick={() => {
if (item.path) {
navigate(item.path);
setIsExpanded(false);
}
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`
flex flex-row items-center gap-2 w-full
relative rounded-lg transition-colors duration-200 no-drag
${!isVertical ? 'px-2 pt-[18px] pb-[14px] min-[1800px]:pl-2 min-[1800px]:pr-4' : 'pl-2 pr-4 py-2.5'}
${active
? 'bg-background-accent text-text-on-accent'
: 'bg-background-default hover:bg-background-medium'
}
`}
>
{/* Drag handle indicator */}
<div className={`opacity-0 group-hover:opacity-100 transition-opacity ${!isVertical ? 'hidden min-[1800px]:block' : ''}`}>
<GripVertical className="w-4 h-4 text-text-muted" />
</div>
{/* Icon */}
{IconComponent && <IconComponent className={`w-5 h-5 flex-shrink-0 ${!isVertical ? 'mx-auto min-[1800px]:mx-0' : ''}`} />}
{/* Label */}
<span className={`text-sm font-medium flex-1 text-left ${!isVertical ? 'hidden min-[1800px]:block' : ''}`}>{item.label}</span>
{/* Tag/Badge */}
{item.getTag && (
<div className={`flex items-center gap-1 ${!isVertical ? 'hidden min-[1800px]:flex' : ''}`}>
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
active
? 'bg-background-default/20 text-text-on-accent/80'
: 'bg-background-muted text-text-muted'
}`}>
{item.getTag()}
</span>
</div>
)}
{/* Update indicator dot */}
{isPulsing && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse"
/>
)}
</motion.button>
</motion.div>
);
})}
{/* Bottom spacer */}
{(isVertical || position === 'top' || position === 'bottom') && (
<div className={`${
isVertical
? 'flex-1 w-full min-h-[60px]'
: position === 'top'
? 'w-[160px]'
: 'flex-1 min-w-[60px]'
} bg-background-default rounded-lg flex items-center justify-center py-2.5`} />
)}
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -0,0 +1,688 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Home, History, FileText, Clock, Puzzle, Settings as SettingsIcon, GripVertical } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChatSmart } from '../icons';
import { listSessions, getSessionInsights } from '../../api';
import { useConfig } from '../ConfigContext';
import { listSavedRecipes } from '../../recipe/recipe_management';
import { useNavigationCustomization } from '../settings/app/NavigationCustomizationSettings';
interface NavItem {
id: string;
path?: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
getTag?: () => string;
tagAlign?: 'left' | 'right';
isWidget?: boolean;
renderContent?: () => React.ReactNode;
}
export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right';
interface TopNavigationProps {
isExpanded: boolean;
setIsExpanded: (expanded: boolean) => void;
position?: NavigationPosition;
isOverlayMode?: boolean;
}
// Analog Clock Widget Component
const AnalogClock: React.FC = () => {
const [secondAngle, setSecondAngle] = useState(() => {
const now = new Date();
return now.getSeconds() * 6;
});
const [angles, setAngles] = useState(() => {
const now = new Date();
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
return {
hour: (hours * 30) + (minutes * 0.5),
minute: minutes * 6,
};
});
useEffect(() => {
const interval = setInterval(() => {
const now = new Date();
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
setSecondAngle(prev => prev + 6);
setAngles({
hour: (hours * 30) + (minutes * 0.5),
minute: minutes * 6,
});
}, 1000);
return () => clearInterval(interval);
}, []);
const hourAngle = angles.hour;
const minuteAngle = angles.minute;
return (
<div className="w-full h-full flex items-center justify-center">
<div className="relative w-20 h-20 sm:w-24 sm:h-24 md:w-28 md:h-28 lg:w-32 lg:h-32 xl:w-36 xl:h-36">
{/* Hour markers (ticks) - hidden on small/medium viewports, visible on large+ */}
{[...Array(12)].map((_, i) => (
<div
key={i}
className="absolute w-0.5 h-2 bg-text-muted/40 left-1/2 -translate-x-1/2 hidden lg:block"
style={{
top: '0.5rem',
transformOrigin: 'center calc(50% + 3.5rem)',
transform: `translateX(-50%) rotate(${i * 30}deg)`,
}}
/>
))}
{/* Hour hand - always visible */}
<div
className="absolute w-1 h-10 bg-text-default rounded-full top-1/2 left-1/2 -translate-x-1/2 origin-bottom transition-transform duration-1000 ease-linear"
style={{
transform: `translateX(-50%) translateY(-100%) rotate(${hourAngle}deg)`,
}}
/>
{/* Minute hand - always visible */}
<div
className="absolute w-0.5 h-14 bg-text-default rounded-full top-1/2 left-1/2 -translate-x-1/2 origin-bottom transition-transform duration-1000 ease-linear"
style={{
transform: `translateX(-50%) translateY(-100%) rotate(${minuteAngle}deg)`,
}}
/>
{/* Second hand - always visible */}
<div
className="absolute w-px h-16 bg-red-500 rounded-full top-1/2 left-1/2 -translate-x-1/2 origin-bottom transition-transform duration-1000 ease-linear"
style={{
transform: `translateX(-50%) translateY(-100%) rotate(${secondAngle}deg)`,
}}
/>
{/* Center dot - always visible */}
<div className="absolute w-3 h-3 bg-text-default rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
</div>
);
};
export const TopNavigation: React.FC<TopNavigationProps> = ({ isExpanded, setIsExpanded, position = 'top', isOverlayMode = false }) => {
const navigate = useNavigate();
const location = useLocation();
const { extensionsList, getExtensions } = useConfig();
const { preferences } = useNavigationCustomization();
const [forceUpdate, setForceUpdate] = useState(0);
const [currentTime, setCurrentTime] = useState('');
const [todayChatsCount, setTodayChatsCount] = useState(0);
const [totalSessions, setTotalSessions] = useState(0);
const [recipesCount, setRecipesCount] = useState(0);
const [totalTokens, setTotalTokens] = useState(0);
// Handle escape key to close overlay
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isExpanded && isOverlayMode) {
event.preventDefault();
event.stopPropagation();
setIsExpanded(false);
}
};
if (isOverlayMode && isExpanded) {
document.addEventListener('keydown', handleKeyDown, { capture: true });
return () => document.removeEventListener('keydown', handleKeyDown, { capture: true });
}
return undefined;
}, [isExpanded, isOverlayMode, setIsExpanded]);
const [sessionHeatmapData, setSessionHeatmapData] = useState<Record<string, number>>({});
const [prevValues, setPrevValues] = useState<Record<string, string>>({});
const [pulsingItems, setPulsingItems] = useState<Set<string>>(new Set());
const [draggedItem, setDraggedItem] = useState<string | null>(null);
const [dragOverItem, setDragOverItem] = useState<string | null>(null);
const [tileOrder, setTileOrder] = useState<string[]>([]);
const [isUltraWide, setIsUltraWide] = useState(false);
useEffect(() => {
const updateTime = () => {
const now = new Date();
setCurrentTime(now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }));
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const checkUltraWide = () => {
setIsUltraWide(window.innerWidth >= 2536);
};
checkUltraWide();
window.addEventListener('resize', checkUltraWide);
return () => window.removeEventListener('resize', checkUltraWide);
}, []);
useEffect(() => {
if (isExpanded) {
fetchNavigationData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isExpanded]);
const fetchNavigationData = async () => {
try {
const sessionsResponse = await listSessions({ throwOnError: false });
if (sessionsResponse.data) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const heatmap: Record<string, number> = {};
let todayCount = 0;
sessionsResponse.data.sessions.forEach((session) => {
const sessionDate = new Date(session.created_at);
const dateKey = sessionDate.toISOString().split('T')[0];
heatmap[dateKey] = (heatmap[dateKey] || 0) + 1;
const sessionDateOnly = new Date(session.created_at);
sessionDateOnly.setHours(0, 0, 0, 0);
if (sessionDateOnly.getTime() === today.getTime()) {
todayCount++;
}
});
setSessionHeatmapData(heatmap);
setTodayChatsCount(todayCount);
}
const insightsResponse = await getSessionInsights({ throwOnError: false });
if (insightsResponse.data) {
setTotalSessions(insightsResponse.data.totalSessions || 0);
setTotalTokens(insightsResponse.data.totalTokens || 0);
}
try {
const recipes = await listSavedRecipes();
setRecipesCount(recipes.length);
} catch (error) {
console.error('Failed to fetch recipes:', error);
setRecipesCount(0);
}
try {
await getExtensions(true);
} catch (error) {
console.error('Failed to fetch extensions:', error);
}
} catch (error) {
console.error('Failed to fetch navigation data:', error);
}
};
const navItemsBase: NavItem[] = [
{
id: 'home',
path: '/',
label: 'Home',
icon: Home,
getTag: () => currentTime,
},
{
id: 'chat',
path: '/pair',
label: 'Chat',
icon: ChatSmart,
getTag: () => `${todayChatsCount} today`,
tagAlign: 'left',
},
{
id: 'history',
path: '/sessions',
label: 'History',
icon: History,
getTag: () => `${totalSessions} total`,
tagAlign: 'left',
},
{
id: 'recipes',
path: '/recipes',
label: 'Recipes',
icon: FileText,
getTag: () => `${recipesCount}`,
},
{
id: 'scheduler',
path: '/schedules',
label: 'Scheduler',
icon: Clock,
},
{
id: 'extensions',
path: '/extensions',
label: 'Extensions',
icon: Puzzle,
getTag: () => {
if (!extensionsList || !Array.isArray(extensionsList)) {
return '0 of 0 enabled';
}
const enabled = extensionsList.filter(ext => ext.enabled).length;
const total = extensionsList.length;
return `${enabled} of ${total} enabled`;
},
},
{
id: 'settings',
path: '/settings',
label: 'Settings',
icon: SettingsIcon,
getTag: () => '✓',
tagAlign: 'left',
},
{
id: 'placeholder-1',
path: '#',
label: '',
},
{
id: 'placeholder-2',
path: '#',
label: '',
},
{
id: 'clock-widget',
label: 'Clock',
isWidget: true,
renderContent: () => <AnalogClock />,
},
{
id: 'activity-widget',
label: 'Activity',
isWidget: true,
renderContent: () => {
const days = 35;
const today = new Date();
const heatmapCells = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateKey = date.toISOString().split('T')[0];
const count = sessionHeatmapData[dateKey] || 0;
const maxCount = Math.max(...Object.values(sessionHeatmapData), 1);
const intensity = count === 0 ? 0 : Math.ceil((count / maxCount) * 4);
heatmapCells.push({ date: dateKey, count, intensity });
}
return (
<div className="w-full h-full flex flex-col items-center justify-center p-4">
<div className="grid grid-cols-7 gap-1">
{heatmapCells.map((cell, index) => (
<div
key={index}
className={`w-3 h-3 rounded-sm ${
cell.intensity === 0 ? 'bg-background-muted' :
cell.intensity === 1 ? 'bg-green-200 dark:bg-green-900' :
cell.intensity === 2 ? 'bg-green-300 dark:bg-green-700' :
cell.intensity === 3 ? 'bg-green-400 dark:bg-green-600' :
'bg-green-500 dark:bg-green-500'
}`}
title={`${cell.date}: ${cell.count} sessions`}
/>
))}
</div>
<div className="mt-3 text-xs text-text-muted font-mono">
Last 35 days
</div>
</div>
);
},
},
{
id: 'tokens-widget',
label: 'Tokens',
isWidget: true,
renderContent: () => {
const tokensInMillions = (totalTokens / 1000000).toFixed(2);
return (
<div className="w-full h-full flex flex-col items-center justify-center p-6">
<div className="text-center">
<div className="text-3xl font-mono font-light text-text-default mb-2">
{tokensInMillions}M
</div>
<div className="text-xs text-text-muted font-mono">
Total tokens
</div>
</div>
</div>
);
},
},
];
const navItems = React.useMemo(() => {
const enabledItems = navItemsBase.filter(item =>
preferences.enabledItems.includes(item.id)
);
const orderedItems = preferences.itemOrder
.map(id => enabledItems.find(item => item.id === id))
.filter(Boolean) as NavItem[];
const itemsNotInOrder = enabledItems.filter(item =>
!preferences.itemOrder.includes(item.id)
);
return [...orderedItems, ...itemsNotInOrder];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItemsBase, preferences.enabledItems, preferences.itemOrder, forceUpdate, currentTime, todayChatsCount, totalSessions, recipesCount, totalTokens]);
useEffect(() => {
const handlePreferencesUpdate = () => {
setForceUpdate(prev => prev + 1);
setTileOrder(prev => [...prev]);
};
window.addEventListener('navigation-preferences-updated', handlePreferencesUpdate);
return () => {
window.removeEventListener('navigation-preferences-updated', handlePreferencesUpdate);
};
}, []);
useEffect(() => {
const currentValues: Record<string, string> = {
chat: `${todayChatsCount}`,
history: `${totalSessions}`,
recipes: `${recipesCount}`,
tokens: `${totalTokens}`,
};
Object.entries(currentValues).forEach(([key, value]) => {
if (prevValues[key] && prevValues[key] !== value) {
setPulsingItems(prev => new Set(prev).add(key));
setTimeout(() => {
setPulsingItems(prev => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 2000);
}
});
setPrevValues(currentValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [todayChatsCount, totalSessions, recipesCount, totalTokens]);
const handleDragStart = (e: React.DragEvent, itemId: string) => {
setDraggedItem(itemId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, itemId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedItem && draggedItem !== itemId) {
setDragOverItem(itemId);
}
};
const handleDrop = (e: React.DragEvent, dropItemId: string) => {
e.preventDefault();
if (!draggedItem || draggedItem === dropItemId) return;
const newOrder = [...tileOrder];
const draggedIndex = newOrder.indexOf(draggedItem);
const dropIndex = newOrder.indexOf(dropItemId);
newOrder.splice(draggedIndex, 1);
newOrder.splice(dropIndex, 0, draggedItem);
setTileOrder(newOrder);
setDraggedItem(null);
setDragOverItem(null);
};
const handleDragEnd = () => {
setDraggedItem(null);
setDragOverItem(null);
};
const isActive = (path: string) => {
return location.pathname === path;
};
const isVertical = position === 'left' || position === 'right';
const gridClasses = isOverlayMode
? 'grid gap-0.5 w-full max-w-7xl mx-auto px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8'
: isVertical
? 'grid grid-cols-1 gap-0.5 h-full overflow-y-auto'
: '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';
const gridStyle = isOverlayMode ? {
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))'
} : undefined;
const containerClasses = isOverlayMode
? 'w-full h-full flex items-center justify-center' // Always centered for overlay mode
: isVertical
? 'h-full'
: 'w-full overflow-hidden';
return (
<div className={`${isOverlayMode ? 'bg-transparent' : 'bg-background-muted'} ${containerClasses} relative z-[9998]`}>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={isOverlayMode ? {
opacity: 0,
scale: 0.95
} : {
[isVertical ? 'width' : 'height']: 0,
opacity: 0
}}
animate={isOverlayMode ? {
opacity: 1,
scale: 1
} : {
[isVertical ? 'width' : 'height']: "auto",
opacity: 1,
}}
exit={isOverlayMode ? {
opacity: 0,
scale: 0.95
} : {
[isVertical ? 'width' : 'height']: 0,
opacity: 0,
}}
transition={isOverlayMode ? {
duration: 0.3,
ease: "easeOut"
} : {
[isVertical ? 'width' : 'height']: {
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.8,
},
opacity: {
duration: 0.3,
ease: "easeInOut"
}
}}
className={`${isOverlayMode ? 'bg-transparent' : 'bg-background-muted overflow-hidden'} ${isVertical && !isOverlayMode ? 'h-full' : ''}`}
>
<motion.div
initial={isOverlayMode ? { opacity: 0 } : { [isVertical ? 'x' : 'y']: -20 }}
animate={isOverlayMode ? { opacity: 1 } : { [isVertical ? 'x' : 'y']: 0 }}
exit={isOverlayMode ? { opacity: 0 } : { [isVertical ? 'x' : 'y']: -20 }}
transition={isOverlayMode ? {
duration: 0.2,
delay: 0.1
} : {
type: "spring",
stiffness: 400,
damping: 25,
}}
className={`${isOverlayMode ? 'overflow-y-auto' : isVertical ? 'p-1 h-full' : 'pb-0.5 lg:max-h-[2000px] md:max-h-[calc(100vh-60px)] max-h-screen'}`}
style={{ width: isVertical && !isOverlayMode ? '360px' : undefined }}
>
<div
className={gridClasses}
style={{
...(isOverlayMode ? gridStyle : {}),
...(!isVertical && !isOverlayMode && isUltraWide ? { gridTemplateColumns: 'repeat(12, minmax(0, 1fr))' } : {}),
...(isVertical && !isOverlayMode ? { width: 'auto' } : {})
}}
>
{navItems.map((item, index) => {
const isPulsing = pulsingItems.has(item.id);
const isDragging = draggedItem === item.id;
const isDragOver = dragOverItem === item.id;
if (item.isWidget) {
return (
<motion.div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent, item.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)}
onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)}
onDragEnd={handleDragEnd}
initial={{ opacity: 0, y: 20, scale: 0.9 }}
animate={{
opacity: 1,
y: 0,
scale: isDragging ? 0.95 : 1,
}}
transition={{
type: "spring",
stiffness: 350,
damping: 25,
delay: index * 0.03,
}}
className={`
relative ${isOverlayMode ? 'bg-background-default backdrop-blur-md' : 'bg-background-default'} rounded-2xl
overflow-hidden cursor-move group
${isDragOver ? 'ring-2 ring-blue-500' : ''}
${isPulsing ? 'animate-pulse' : ''}
aspect-square
`}
style={{
opacity: isDragging ? 0.5 : 1,
}}
>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<GripVertical className="w-4 h-4 text-text-muted" />
</div>
{item.renderContent && item.renderContent()}
</motion.div>
);
}
const IconComponent = item.icon!;
const active = isActive(item.path!);
return (
<motion.div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent, item.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)}
onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)}
onDragEnd={handleDragEnd}
initial={{ opacity: 0, y: 20, scale: 0.9 }}
animate={{
opacity: 1,
y: 0,
scale: isDragging ? 0.95 : 1,
}}
transition={{
type: "spring",
stiffness: 350,
damping: 25,
delay: index * 0.03,
}}
className={`
relative cursor-move group
${isDragOver ? 'ring-2 ring-blue-500 rounded-2xl' : ''}
`}
style={{
opacity: isDragging ? 0.5 : 1,
}}
>
<motion.button
onClick={() => {
navigate(item.path!);
setIsExpanded(false);
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`
w-full relative flex flex-col items-start justify-between
rounded-2xl
${isOverlayMode ? 'px-5 py-5' : 'px-6 py-6'}
transition-colors duration-200
no-drag
${isOverlayMode
? (active
? 'bg-background-accent text-text-on-accent backdrop-blur-md'
: 'bg-background-default text-text-default hover:bg-background-medium backdrop-blur-md'
)
: (active
? 'bg-background-accent text-text-on-accent'
: 'bg-background-default hover:bg-background-medium'
)
}
aspect-square
`}
>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<GripVertical className="w-4 h-4 text-text-muted" />
</div>
{isPulsing && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="absolute bottom-3 right-3 w-2 h-2 bg-blue-500 rounded-full animate-pulse"
/>
)}
{item.getTag && (
<div className={`absolute ${isOverlayMode ? 'top-3' : 'top-4'} px-2 py-1 rounded-full ${
item.tagAlign === 'left' ? 'left-3' : 'right-3'
} ${isOverlayMode ? 'bg-background-muted backdrop-blur-sm' : 'bg-background-muted'}`}>
<span className={`${isOverlayMode ? 'text-[10px]' : 'text-xs'} font-mono ${isOverlayMode ? 'text-text-muted' : 'text-text-muted'}`}>{item.getTag()}</span>
</div>
)}
<div className="mt-auto w-full">
{IconComponent && <IconComponent className={`${isOverlayMode ? 'w-6 h-6 mb-2' : 'w-6 h-6 mb-2'}`} />}
{item.label && <h2 className={`font-light text-left ${isOverlayMode ? 'text-xl' : 'text-2xl'}`}>{item.label}</h2>}
</div>
</motion.button>
</motion.div>
);
})}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -29,11 +29,21 @@ export default function SettingsView({
setView: (view: View, viewOptions?: ViewOptions) => void;
viewOptions: SettingsViewOptions;
}) {
const [activeTab, setActiveTab] = useState('models');
const [activeTab, setActiveTab] = useState(() => {
// Persist active tab in sessionStorage to survive re-renders
if (typeof window !== 'undefined' && window.sessionStorage) {
const stored = window.sessionStorage.getItem('settings_active_tab');
return stored || 'models';
}
return 'models';
});
const hasTrackedInitialTab = useRef(false);
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (typeof window !== 'undefined' && window.sessionStorage) {
window.sessionStorage.setItem('settings_active_tab', tab);
}
trackSettingsTabViewed(tab);
};
@@ -1,14 +1,19 @@
import { useState, useEffect, useRef } from 'react';
import { Switch } from '../../ui/switch';
import { Button } from '../../ui/button';
import { Settings } from 'lucide-react';
import { Settings, ChevronDown } from 'lucide-react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../ui/collapsible';
import UpdateSection from './UpdateSection';
import TunnelSection from '../tunnel/TunnelSection';
import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
import ThemeSelector from '../../GooseSidebar/ThemeSelector';
import { NavigationPositionSelector } from './NavigationPositionSelector';
import { NavigationStyleSelector } from './NavigationStyleSelector';
import { NavigationModeSelector } from './NavigationModeSelector';
import { NavigationCustomizationSettings } from './NavigationCustomizationSettings';
import BlockLogoBlack from './icons/block-lockup_black.png';
import BlockLogoWhite from './icons/block-lockup_white.png';
import TelemetrySettings from './TelemetrySettings';
@@ -27,6 +32,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [showPricing, setShowPricing] = useState(true);
const [isDarkMode, setIsDarkMode] = useState(false);
const [navigationMode, setNavigationMode] = useState<'push' | 'overlay'>('push');
const updateSectionRef = useRef<HTMLDivElement>(null);
// Check if GOOSE_VERSION is set to determine if Updates section should be shown
@@ -62,6 +68,22 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
setShowPricing(stored !== 'false');
}, []);
// Load navigation mode and listen for changes
useEffect(() => {
const stored = localStorage.getItem('navigation_mode');
setNavigationMode((stored as 'push' | 'overlay') || 'push');
const handleModeChange = (event: Event) => {
const customEvent = event as CustomEvent<{ mode: 'push' | 'overlay' }>;
setNavigationMode(customEvent.detail.mode);
};
window.addEventListener('navigation-mode-changed', handleModeChange);
return () => {
window.removeEventListener('navigation-mode-changed', handleModeChange);
};
}, []);
// Handle scrolling to update section
useEffect(() => {
if (scrollToSection === 'update' && updateSectionRef.current) {
@@ -269,6 +291,69 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
</CardContent>
</Card>
<Card className="rounded-lg">
<Collapsible defaultOpen={true}>
<CardHeader className="pb-0">
<CollapsibleTrigger className="w-full group">
<div className="flex items-center justify-between w-full">
<div className="text-left">
<CardTitle className="mb-1">Navigation</CardTitle>
<CardDescription>Customize navigation appearance, position, and items</CardDescription>
</div>
<ChevronDown className="w-5 h-5 text-text-muted transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-4 px-4 space-y-6">
{/* Navigation Mode */}
<div className="space-y-2">
<div>
<h3 className="text-sm font-medium text-text-default">Mode</h3>
<p className="text-xs text-text-muted">
{navigationMode === 'overlay'
? 'Overlay mode: Centered expanded tiles launcher'
: 'Choose between push menu or overlay launcher'}
</p>
</div>
<NavigationModeSelector />
</div>
{/* Navigation Position - Only show for push mode */}
{navigationMode === 'push' && (
<div className="space-y-2">
<div>
<h3 className="text-sm font-medium text-text-default">Position</h3>
<p className="text-xs text-text-muted">Choose where the navigation bar appears</p>
</div>
<NavigationPositionSelector />
</div>
)}
{/* Navigation Style - Only show for push mode */}
{navigationMode === 'push' && (
<div className="space-y-2">
<div>
<h3 className="text-sm font-medium text-text-default">Style</h3>
<p className="text-xs text-text-muted">Choose between expanded tiles or condensed rows</p>
</div>
<NavigationStyleSelector />
</div>
)}
{/* Navigation Items */}
<div className="space-y-2">
<div>
<h3 className="text-sm font-medium text-text-default">Items</h3>
<p className="text-xs text-text-muted">Customize which items appear and their order</p>
</div>
<NavigationCustomizationSettings />
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
<TunnelSection />
<TelemetrySettings isWelcome={false} />
@@ -0,0 +1,193 @@
import React, { useState, useCallback } from 'react';
import { GripVertical, Eye, EyeOff } from 'lucide-react';
export interface NavigationPreferences {
itemOrder: string[];
enabledItems: string[];
}
const DEFAULT_ITEM_ORDER = [
'home',
'chat',
'history',
'recipes',
'scheduler',
'extensions',
'settings',
'placeholder-1',
'placeholder-2',
'clock-widget',
'activity-widget',
'tokens-widget',
];
const DEFAULT_ENABLED_ITEMS = [...DEFAULT_ITEM_ORDER];
const ITEM_LABELS: Record<string, string> = {
home: 'Home',
chat: 'Chat',
history: 'History',
recipes: 'Recipes',
scheduler: 'Scheduler',
extensions: 'Extensions',
settings: 'Settings',
'placeholder-1': 'Coming Soon 1',
'placeholder-2': 'Coming Soon 2',
'clock-widget': 'Clock Widget',
'activity-widget': 'Activity Widget',
'tokens-widget': 'Tokens Widget',
};
export const useNavigationCustomization = () => {
const [preferences, setPreferences] = useState<NavigationPreferences>(() => {
const stored = localStorage.getItem('navigation_preferences');
if (stored) {
try {
return JSON.parse(stored);
} catch (e) {
console.error('Failed to parse navigation preferences:', e);
}
}
return {
itemOrder: DEFAULT_ITEM_ORDER,
enabledItems: DEFAULT_ENABLED_ITEMS,
};
});
const updatePreferences = useCallback((newPreferences: NavigationPreferences) => {
setPreferences(newPreferences);
localStorage.setItem('navigation_preferences', JSON.stringify(newPreferences));
window.dispatchEvent(
new CustomEvent('navigation-preferences-updated', {
detail: newPreferences,
})
);
}, []);
return { preferences, updatePreferences };
};
interface NavigationCustomizationSettingsProps {
className?: string;
}
export const NavigationCustomizationSettings: React.FC<NavigationCustomizationSettingsProps> = ({
className,
}) => {
const { preferences, updatePreferences } = useNavigationCustomization();
const [draggedItem, setDraggedItem] = useState<string | null>(null);
const [dragOverItem, setDragOverItem] = useState<string | null>(null);
const handleDragStart = (e: React.DragEvent, itemId: string) => {
setDraggedItem(itemId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, itemId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedItem && draggedItem !== itemId) {
setDragOverItem(itemId);
}
};
const handleDrop = (e: React.DragEvent, dropItemId: string) => {
e.preventDefault();
if (!draggedItem || draggedItem === dropItemId) return;
const newOrder = [...preferences.itemOrder];
const draggedIndex = newOrder.indexOf(draggedItem);
const dropIndex = newOrder.indexOf(dropItemId);
newOrder.splice(draggedIndex, 1);
newOrder.splice(dropIndex, 0, draggedItem);
updatePreferences({
...preferences,
itemOrder: newOrder,
});
setDraggedItem(null);
setDragOverItem(null);
};
const handleDragEnd = () => {
setDraggedItem(null);
setDragOverItem(null);
};
const toggleItemEnabled = (itemId: string) => {
const newEnabledItems = preferences.enabledItems.includes(itemId)
? preferences.enabledItems.filter((id) => id !== itemId)
: [...preferences.enabledItems, itemId];
updatePreferences({
...preferences,
enabledItems: newEnabledItems,
});
};
const resetToDefaults = () => {
updatePreferences({
itemOrder: DEFAULT_ITEM_ORDER,
enabledItems: DEFAULT_ENABLED_ITEMS,
});
};
return (
<div className={className}>
<div className="space-y-3">
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-text-muted">
Drag to reorder, click the eye icon to show/hide items
</p>
<button
onClick={resetToDefaults}
className="text-xs text-text-muted hover:text-text-default transition-colors"
>
Reset to defaults
</button>
</div>
{preferences.itemOrder.map((itemId) => {
const isEnabled = preferences.enabledItems.includes(itemId);
const isDragging = draggedItem === itemId;
const isDragOver = dragOverItem === itemId;
return (
<div
key={itemId}
draggable
onDragStart={(e) => handleDragStart(e, itemId)}
onDragOver={(e) => handleDragOver(e, itemId)}
onDrop={(e) => handleDrop(e, itemId)}
onDragEnd={handleDragEnd}
className={`
flex items-center gap-3 p-3 rounded-lg border transition-all
${isDragging ? 'opacity-50' : 'opacity-100'}
${isDragOver ? 'border-border-strong bg-background-medium' : 'border-border-subtle bg-background-default'}
${!isEnabled ? 'opacity-50' : ''}
`}
>
<GripVertical className="w-4 h-4 text-text-muted cursor-move" />
<span className="flex-1 text-sm text-text-default">
{ITEM_LABELS[itemId] || itemId}
</span>
<button
onClick={() => toggleItemEnabled(itemId)}
className="p-1 rounded hover:bg-background-medium transition-colors"
title={isEnabled ? 'Hide item' : 'Show item'}
>
{isEnabled ? (
<Eye className="w-4 h-4 text-text-default" />
) : (
<EyeOff className="w-4 h-4 text-text-muted" />
)}
</button>
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,96 @@
import React, { useState, useEffect } from 'react';
import { PanelLeft, Layers } from 'lucide-react';
export type NavigationMode = 'push' | 'overlay';
interface NavigationModeSelectorProps {
className?: string;
}
export const NavigationModeSelector: React.FC<NavigationModeSelectorProps> = ({ className }) => {
const [selectedMode, setSelectedMode] = useState<NavigationMode>(() => {
const stored = localStorage.getItem('navigation_mode');
return (stored as NavigationMode) || 'push';
});
useEffect(() => {
localStorage.setItem('navigation_mode', selectedMode);
// When overlay mode is selected, automatically set position to center and style to expanded
// Use requestAnimationFrame to batch updates and prevent layout thrashing
if (selectedMode === 'overlay') {
localStorage.setItem('navigation_position', 'top'); // Center position
localStorage.setItem('navigation_style', 'expanded');
// Batch all events together in a single frame to prevent multiple re-renders
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent('navigation-position-changed', {
detail: { position: 'top' },
})
);
window.dispatchEvent(
new CustomEvent('navigation-style-changed', {
detail: { style: 'expanded' },
})
);
window.dispatchEvent(
new CustomEvent('navigation-mode-changed', {
detail: { mode: selectedMode },
})
);
});
} else {
// For push mode, only dispatch mode change event
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent('navigation-mode-changed', {
detail: { mode: selectedMode },
})
);
});
}
}, [selectedMode]);
const modes: { value: NavigationMode; label: string; icon: React.ReactNode; description: string }[] = [
{
value: 'push',
label: 'Push',
icon: <PanelLeft className="w-5 h-5" />,
description: 'Navigation pushes content',
},
{
value: 'overlay',
label: 'Overlay',
icon: <Layers className="w-5 h-5" />,
description: 'Full-screen overlay',
},
];
return (
<div className={className}>
<div className="grid grid-cols-2 gap-3">
{modes.map((mode) => (
<button
key={mode.value}
onClick={() => setSelectedMode(mode.value)}
className={`
flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all
${
selectedMode === mode.value
? 'border-border-strong bg-background-medium'
: 'border-border-subtle bg-background-default hover:border-border-medium'
}
`}
>
<div className="text-text-default">{mode.icon}</div>
<div className="text-center">
<div className="text-sm font-medium text-text-default">{mode.label}</div>
<div className="text-xs text-text-muted mt-1">{mode.description}</div>
</div>
</button>
))}
</div>
</div>
);
};
@@ -0,0 +1,71 @@
import React, { useState, useEffect } from 'react';
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from 'lucide-react';
export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right';
interface NavigationPositionSelectorProps {
className?: string;
}
export const NavigationPositionSelector: React.FC<NavigationPositionSelectorProps> = ({ className }) => {
const [selectedPosition, setSelectedPosition] = useState<NavigationPosition>(() => {
const stored = localStorage.getItem('navigation_position');
return (stored as NavigationPosition) || 'top';
});
useEffect(() => {
localStorage.setItem('navigation_position', selectedPosition);
window.dispatchEvent(
new CustomEvent('navigation-position-changed', {
detail: { position: selectedPosition },
})
);
}, [selectedPosition]);
const positions: { value: NavigationPosition; label: string; icon: React.ReactNode }[] = [
{
value: 'top',
label: 'Top',
icon: <ArrowUp className="w-5 h-5" />,
},
{
value: 'bottom',
label: 'Bottom',
icon: <ArrowDown className="w-5 h-5" />,
},
{
value: 'left',
label: 'Left',
icon: <ArrowLeft className="w-5 h-5" />,
},
{
value: 'right',
label: 'Right',
icon: <ArrowRight className="w-5 h-5" />,
},
];
return (
<div className={className}>
<div className="grid grid-cols-4 gap-3">
{positions.map((position) => (
<button
key={position.value}
onClick={() => setSelectedPosition(position.value)}
className={`
flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all
${
selectedPosition === position.value
? 'border-border-strong bg-background-medium'
: 'border-border-subtle bg-background-default hover:border-border-medium'
}
`}
>
<div className="text-text-default">{position.icon}</div>
<div className="text-xs font-medium text-text-default">{position.label}</div>
</button>
))}
</div>
</div>
);
};
@@ -0,0 +1,66 @@
import React, { useState, useEffect } from 'react';
import { LayoutGrid, List } from 'lucide-react';
export type NavigationStyle = 'expanded' | 'condensed';
interface NavigationStyleSelectorProps {
className?: string;
}
export const NavigationStyleSelector: React.FC<NavigationStyleSelectorProps> = ({ className }) => {
const [selectedStyle, setSelectedStyle] = useState<NavigationStyle>(() => {
const stored = localStorage.getItem('navigation_style');
return (stored as NavigationStyle) || 'expanded';
});
useEffect(() => {
localStorage.setItem('navigation_style', selectedStyle);
window.dispatchEvent(
new CustomEvent('navigation-style-changed', {
detail: { style: selectedStyle },
})
);
}, [selectedStyle]);
const styles: { value: NavigationStyle; label: string; icon: React.ReactNode; description: string }[] = [
{
value: 'expanded',
label: 'Expanded',
icon: <LayoutGrid className="w-5 h-5" />,
description: 'Large tiles with widgets',
},
{
value: 'condensed',
label: 'Condensed',
icon: <List className="w-5 h-5" />,
description: 'Compact rows',
},
];
return (
<div className={className}>
<div className="grid grid-cols-2 gap-3">
{styles.map((style) => (
<button
key={style.value}
onClick={() => setSelectedStyle(style.value)}
className={`
flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all
${
selectedStyle === style.value
? 'border-border-strong bg-background-medium'
: 'border-border-subtle bg-background-default hover:border-border-medium'
}
`}
>
<div className="text-text-default">{style.icon}</div>
<div className="text-center">
<div className="text-sm font-medium text-text-default">{style.label}</div>
<div className="text-xs text-text-muted mt-1">{style.description}</div>
</div>
</button>
))}
</div>
</div>
);
};
+117
View File
@@ -776,3 +776,120 @@ p > code.bg-inline-code {
.katex-error {
color: var(--text-danger);
}
/* Navigation Component Animations */
@keyframes nav-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes nav-scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes nav-slide-in-vertical {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes nav-slide-in-horizontal {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes nav-tile-in {
from {
opacity: 0;
transform: translateY(20px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Navigation overlay animations */
.nav-overlay-enter {
animation: nav-scale-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.nav-overlay-content {
animation: nav-fade-in 0.2s ease-out 0.1s forwards;
opacity: 0;
}
/* Navigation tile stagger animation */
.nav-tile {
animation: nav-tile-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
opacity: 0;
}
/* Stagger delays for tiles */
.nav-tile:nth-child(1) { animation-delay: 0ms; }
.nav-tile:nth-child(2) { animation-delay: 30ms; }
.nav-tile:nth-child(3) { animation-delay: 60ms; }
.nav-tile:nth-child(4) { animation-delay: 90ms; }
.nav-tile:nth-child(5) { animation-delay: 120ms; }
.nav-tile:nth-child(6) { animation-delay: 150ms; }
.nav-tile:nth-child(7) { animation-delay: 180ms; }
.nav-tile:nth-child(8) { animation-delay: 210ms; }
.nav-tile:nth-child(9) { animation-delay: 240ms; }
.nav-tile:nth-child(10) { animation-delay: 270ms; }
.nav-tile:nth-child(11) { animation-delay: 300ms; }
.nav-tile:nth-child(12) { animation-delay: 330ms; }
/* Navigation push mode animations */
.nav-push-enter {
animation: nav-slide-in-vertical 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Hover and active states for navigation buttons */
.nav-button {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
background-color 0.2s ease;
}
.nav-button:hover {
transform: scale(1.02);
}
.nav-button:active {
transform: scale(0.98);
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
.nav-overlay-enter,
.nav-overlay-content,
.nav-tile,
.nav-push-enter,
.nav-button {
animation: none !important;
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}