diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 39ffef97..adc68ac2 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -75,14 +75,7 @@ import { DeleteProjectDialog } from '@/components/views/settings-view/components import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; import type { FeatureCount } from '@/components/views/spec-view/types'; -import { - DndContext, - DragEndEvent, - PointerSensor, - useSensor, - useSensors, - closestCenter, -} from '@dnd-kit/core'; +import { DndContext, closestCenter } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { getHttpApiClient } from '@/lib/http-api-client'; import type { StarterTemplate } from '@/lib/templates'; @@ -95,6 +88,7 @@ import { PROJECT_LIGHT_THEMES, SIDEBAR_FEATURE_FLAGS, } from './sidebar/constants'; +import { useThemePreview, useSidebarAutoCollapse, useDragAndDrop } from './sidebar/hooks'; export function Sidebar() { const navigate = useNavigate(); @@ -164,45 +158,8 @@ export function Sidebar() { const [featureCount, setFeatureCount] = useState(50); const [showSpecIndicator, setShowSpecIndicator] = useState(true); - // Debounced preview theme handlers to prevent excessive re-renders - const previewTimeoutRef = useRef | null>(null); - - const handlePreviewEnter = useCallback( - (value: string) => { - // Clear any pending timeout - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - } - // Small delay to debounce rapid hover changes - previewTimeoutRef.current = setTimeout(() => { - setPreviewTheme(value as ThemeMode); - }, 16); // ~1 frame delay - }, - [setPreviewTheme] - ); - - const handlePreviewLeave = useCallback( - (e: React.PointerEvent) => { - const relatedTarget = e.relatedTarget as HTMLElement; - if (!relatedTarget?.closest('[data-testid^="project-theme-"]')) { - // Clear any pending timeout - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - } - setPreviewTheme(null); - } - }, - [setPreviewTheme] - ); - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - } - }; - }, []); + // Debounced preview theme handlers + const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; @@ -212,23 +169,7 @@ export function Sidebar() { const projectSearchInputRef = useRef(null); // Auto-collapse sidebar on small screens - useEffect(() => { - const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint - - const handleResize = () => { - if (mediaQuery.matches && sidebarOpen) { - // Auto-collapse on small screens - toggleSidebar(); - } - }; - - // Check on mount - handleResize(); - - // Listen for changes - mediaQuery.addEventListener('change', handleResize); - return () => mediaQuery.removeEventListener('change', handleResize); - }, [sidebarOpen, toggleSidebar]); + useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); // Filtered projects based on search query const filteredProjects = useMemo(() => { @@ -262,31 +203,8 @@ export function Sidebar() { } }, [isProjectPickerOpen]); - // Sensors for drag-and-drop - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 5, // Small distance to start drag - }, - }) - ); - - // Handle drag end for reordering projects - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = projects.findIndex((p) => p.id === active.id); - const newIndex = projects.findIndex((p) => p.id === over.id); - - if (oldIndex !== -1 && newIndex !== -1) { - reorderProjects(oldIndex, newIndex); - } - } - }, - [projects, reorderProjects] - ); + // Drag-and-drop for project reordering + const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); // Subscribe to spec regeneration events useEffect(() => { diff --git a/apps/ui/src/components/layout/sidebar/hooks/index.ts b/apps/ui/src/components/layout/sidebar/hooks/index.ts new file mode 100644 index 00000000..0255a7e5 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts @@ -0,0 +1,3 @@ +export { useThemePreview } from './use-theme-preview'; +export { useSidebarAutoCollapse } from './use-sidebar-auto-collapse'; +export { useDragAndDrop } from './use-drag-and-drop'; diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts b/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts new file mode 100644 index 00000000..570264a4 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useSensors, useSensor, PointerSensor, type DragEndEvent } from '@dnd-kit/core'; +import type { Project } from '@/lib/electron'; + +interface UseDragAndDropProps { + projects: Project[]; + reorderProjects: (oldIndex: number, newIndex: number) => void; +} + +export function useDragAndDrop({ projects, reorderProjects }: UseDragAndDropProps) { + // Sensors for drag-and-drop + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, // Small distance to start drag + }, + }) + ); + + // Handle drag end for reordering projects + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = projects.findIndex((p) => p.id === active.id); + const newIndex = projects.findIndex((p) => p.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + reorderProjects(oldIndex, newIndex); + } + } + }, + [projects, reorderProjects] + ); + + return { + sensors, + handleDragEnd, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts new file mode 100644 index 00000000..994da088 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +interface UseSidebarAutoCollapseProps { + sidebarOpen: boolean; + toggleSidebar: () => void; +} + +export function useSidebarAutoCollapse({ + sidebarOpen, + toggleSidebar, +}: UseSidebarAutoCollapseProps) { + // Auto-collapse sidebar on small screens + useEffect(() => { + const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint + + const handleResize = () => { + if (mediaQuery.matches && sidebarOpen) { + // Auto-collapse on small screens + toggleSidebar(); + } + }; + + // Check on mount + handleResize(); + + // Listen for changes + mediaQuery.addEventListener('change', handleResize); + return () => mediaQuery.removeEventListener('change', handleResize); + }, [sidebarOpen, toggleSidebar]); +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts b/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts new file mode 100644 index 00000000..46c25e93 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts @@ -0,0 +1,53 @@ +import { useRef, useCallback, useEffect } from 'react'; +import type { ThemeMode } from '@/store/app-store'; + +interface UseThemePreviewProps { + setPreviewTheme: (theme: ThemeMode | null) => void; +} + +export function useThemePreview({ setPreviewTheme }: UseThemePreviewProps) { + // Debounced preview theme handlers to prevent excessive re-renders + const previewTimeoutRef = useRef | null>(null); + + const handlePreviewEnter = useCallback( + (value: string) => { + // Clear any pending timeout + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + // Small delay to debounce rapid hover changes + previewTimeoutRef.current = setTimeout(() => { + setPreviewTheme(value as ThemeMode); + }, 16); // ~1 frame delay + }, + [setPreviewTheme] + ); + + const handlePreviewLeave = useCallback( + (e: React.PointerEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('[data-testid^="project-theme-"]')) { + // Clear any pending timeout + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + setPreviewTheme(null); + } + }, + [setPreviewTheme] + ); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + }; + }, []); + + return { + handlePreviewEnter, + handlePreviewLeave, + }; +}