From 0ddd672e0eeca19c634b8a01a77f1c6a88e0f804 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:53:15 +0000 Subject: [PATCH 1/3] feat: Add Discord-like project switcher sidebar with icon support - Add project icon field to ProjectRef and Project types - Create vertical project switcher sidebar component - Project icons with hover tooltips - Active project highlighting - Plus button to create new projects - Right-click context menu for edit/delete - Add IconPicker component with 35+ Lucide icons - Add EditProjectDialog for inline project editing - Update settings appearance section with project details editor - Add setProjectIcon and setProjectName actions to app store - Integrate ProjectSwitcher in root layout (shows on app pages only) Implements #469 Co-authored-by: Web Dev Cody --- .../components/edit-project-dialog.tsx | 74 ++++++++++ .../components/icon-picker.tsx | 131 ++++++++++++++++++ .../project-switcher/components/index.ts | 4 + .../components/project-context-menu.tsx | 110 +++++++++++++++ .../components/project-switcher-item.tsx | 78 +++++++++++ .../layout/project-switcher/index.ts | 1 + .../project-switcher/project-switcher.tsx | 94 +++++++++++++ .../appearance/appearance-section.tsx | 92 +++++++++++- apps/ui/src/lib/electron.ts | 1 + apps/ui/src/routes/__root.tsx | 6 + apps/ui/src/store/app-store.ts | 34 +++++ libs/types/src/settings.ts | 2 + 12 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx create mode 100644 apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx create mode 100644 apps/ui/src/components/layout/project-switcher/components/index.ts create mode 100644 apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx create mode 100644 apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx create mode 100644 apps/ui/src/components/layout/project-switcher/index.ts create mode 100644 apps/ui/src/components/layout/project-switcher/project-switcher.tsx diff --git a/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx new file mode 100644 index 00000000..0059b2fa --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAppStore } from '@/store/app-store'; +import type { Project } from '@/lib/electron'; +import { IconPicker } from './icon-picker'; + +interface EditProjectDialogProps { + project: Project; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) { + const { setProjectName, setProjectIcon } = useAppStore(); + const [name, setName] = useState(project.name); + const [icon, setIcon] = useState(project.icon || null); + + const handleSave = () => { + if (name.trim() !== project.name) { + setProjectName(project.id, name.trim()); + } + if (icon !== project.icon) { + setProjectIcon(project.id, icon); + } + onOpenChange(false); + }; + + return ( + + + + Edit Project + + +
+ {/* Project Name */} +
+ + setName(e.target.value)} + placeholder="Enter project name" + /> +
+ + {/* Icon Picker */} +
+ + +
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx new file mode 100644 index 00000000..c5a4c1ad --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { X, Search } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface IconPickerProps { + selectedIcon: string | null; + onSelectIcon: (icon: string | null) => void; +} + +// Popular project-related icons +const POPULAR_ICONS = [ + 'Folder', + 'FolderOpen', + 'FolderCode', + 'FolderGit', + 'FolderKanban', + 'Package', + 'Box', + 'Boxes', + 'Code', + 'Code2', + 'Braces', + 'FileCode', + 'Terminal', + 'Globe', + 'Server', + 'Database', + 'Layout', + 'Layers', + 'Blocks', + 'Component', + 'Puzzle', + 'Cog', + 'Wrench', + 'Hammer', + 'Zap', + 'Rocket', + 'Sparkles', + 'Star', + 'Heart', + 'Shield', + 'Lock', + 'Key', + 'Cpu', + 'CircuitBoard', + 'Workflow', +]; + +export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) { + const [search, setSearch] = useState(''); + + const filteredIcons = POPULAR_ICONS.filter((icon) => + icon.toLowerCase().includes(search.toLowerCase()) + ); + + const getIconComponent = (iconName: string) => { + return (LucideIcons as Record>)[iconName]; + }; + + return ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search icons..." + className="pl-9" + /> +
+ + {/* Selected Icon Display */} + {selectedIcon && ( +
+
+ {(() => { + const IconComponent = getIconComponent(selectedIcon); + return IconComponent ? ( + + ) : null; + })()} + {selectedIcon} +
+ +
+ )} + + {/* Icons Grid */} + +
+ {filteredIcons.map((iconName) => { + const IconComponent = getIconComponent(iconName); + if (!IconComponent) return null; + + const isSelected = selectedIcon === iconName; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/index.ts b/apps/ui/src/components/layout/project-switcher/components/index.ts new file mode 100644 index 00000000..86073ca1 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/index.ts @@ -0,0 +1,4 @@ +export { ProjectSwitcherItem } from './project-switcher-item'; +export { ProjectContextMenu } from './project-context-menu'; +export { EditProjectDialog } from './edit-project-dialog'; +export { IconPicker } from './icon-picker'; diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx new file mode 100644 index 00000000..93b0091a --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react'; +import { Edit2, Trash2, ImageIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import type { Project } from '@/lib/electron'; +import { EditProjectDialog } from './edit-project-dialog'; + +interface ProjectContextMenuProps { + project: Project; + position: { x: number; y: number }; + onClose: () => void; +} + +export function ProjectContextMenu({ project, position, onClose }: ProjectContextMenuProps) { + const menuRef = useRef(null); + const { moveProjectToTrash } = useAppStore(); + const [showEditDialog, setShowEditDialog] = useState(false); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [onClose]); + + const handleEdit = () => { + setShowEditDialog(true); + onClose(); + }; + + const handleRemove = () => { + if (confirm(`Move "${project.name}" to trash?`)) { + moveProjectToTrash(project.id); + } + onClose(); + }; + + return ( + <> +
+
+ + + +
+
+ + {showEditDialog && ( + + )} + + ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx new file mode 100644 index 00000000..fc7794c2 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx @@ -0,0 +1,78 @@ +import { Folder, LucideIcon } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Project } from '@/lib/electron'; + +interface ProjectSwitcherItemProps { + project: Project; + isActive: boolean; + onClick: () => void; + onContextMenu: (event: React.MouseEvent) => void; +} + +export function ProjectSwitcherItem({ + project, + isActive, + onClick, + onContextMenu, +}: ProjectSwitcherItemProps) { + // Get the icon component from lucide-react + const getIconComponent = (): LucideIcon => { + if (project.icon && project.icon in LucideIcons) { + return (LucideIcons as Record)[project.icon]; + } + return Folder; + }; + + const IconComponent = getIconComponent(); + + return ( + + ); +} diff --git a/apps/ui/src/components/layout/project-switcher/index.ts b/apps/ui/src/components/layout/project-switcher/index.ts new file mode 100644 index 00000000..f540a4f6 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/index.ts @@ -0,0 +1 @@ +export { ProjectSwitcher } from './project-switcher'; diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx new file mode 100644 index 00000000..dc08ef96 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import { Plus } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { ProjectSwitcherItem } from './components/project-switcher-item'; +import { ProjectContextMenu } from './components/project-context-menu'; +import type { Project } from '@/lib/electron'; + +export function ProjectSwitcher() { + const navigate = useNavigate(); + const { projects, currentProject, setCurrentProject } = useAppStore(); + const [contextMenuProject, setContextMenuProject] = useState(null); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( + null + ); + + const handleContextMenu = (project: Project, event: React.MouseEvent) => { + event.preventDefault(); + setContextMenuProject(project); + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + }; + + const handleCloseContextMenu = () => { + setContextMenuProject(null); + setContextMenuPosition(null); + }; + + const handleProjectClick = (project: Project) => { + setCurrentProject(project); + // Navigate to board view when switching projects + navigate({ to: '/board' }); + }; + + const handleNewProject = () => { + // Navigate to dashboard where users can create new projects + navigate({ to: '/dashboard' }); + }; + + return ( + <> + + + {/* Context Menu */} + {contextMenuProject && contextMenuPosition && ( + + )} + + ); +} diff --git a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx index 7f9ce3a6..0858765a 100644 --- a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx +++ b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx @@ -1,8 +1,12 @@ import { useState } from 'react'; import { Label } from '@/components/ui/label'; -import { Palette, Moon, Sun } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Palette, Moon, Sun, Edit2 } from 'lucide-react'; import { darkThemes, lightThemes } from '@/config/theme-options'; import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker'; import type { Theme, Project } from '../shared/types'; interface AppearanceSectionProps { @@ -16,10 +20,33 @@ export function AppearanceSection({ currentProject, onThemeChange, }: AppearanceSectionProps) { + const { setProjectIcon, setProjectName } = useAppStore(); const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark'); + const [editingProject, setEditingProject] = useState(false); + const [projectName, setProjectNameLocal] = useState(currentProject?.name || ''); + const [projectIcon, setProjectIconLocal] = useState( + (currentProject as any)?.icon || null + ); const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes; + const handleSaveProjectDetails = () => { + if (!currentProject) return; + if (projectName.trim() !== currentProject.name) { + setProjectName(currentProject.id, projectName.trim()); + } + if (projectIcon !== (currentProject as any)?.icon) { + setProjectIcon(currentProject.id, projectIcon); + } + setEditingProject(false); + }; + + const handleCancelEdit = () => { + setProjectNameLocal(currentProject?.name || ''); + setProjectIconLocal((currentProject as any)?.icon || null); + setEditingProject(false); + }; + return (
-
+
+ {/* Project Details Section */} + {currentProject && ( +
+
+ + {!editingProject && ( + + )} +
+ + {editingProject ? ( +
+
+ + setProjectNameLocal(e.target.value)} + placeholder="Enter project name" + /> +
+ +
+ + +
+ +
+ + +
+
+ ) : ( +
+
+
+
{currentProject.name}
+
{currentProject.path}
+
+
+
+ )} +
+ )} + + {/* Theme Section */}