mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user