mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
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 <webdevcody@users.noreply.github.com>
This commit is contained in:
@@ -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<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Project</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Project Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="project-name">Project Name</Label>
|
||||||
|
<Input
|
||||||
|
id="project-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter project name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon Picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Project Icon</Label>
|
||||||
|
<IconPicker selectedIcon={icon} onSelectIcon={setIcon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, React.ComponentType<{ className?: string }>>)[iconName];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search icons..."
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Icon Display */}
|
||||||
|
{selectedIcon && (
|
||||||
|
<div className="flex items-center gap-2 p-2 rounded-md bg-accent/50 border border-border">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
{(() => {
|
||||||
|
const IconComponent = getIconComponent(selectedIcon);
|
||||||
|
return IconComponent ? (
|
||||||
|
<IconComponent className="w-5 h-5 text-brand-500" />
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
<span className="text-sm font-medium">{selectedIcon}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectIcon(null)}
|
||||||
|
className="p-1 hover:bg-background rounded transition-colors"
|
||||||
|
title="Clear icon"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icons Grid */}
|
||||||
|
<ScrollArea className="h-64 rounded-md border">
|
||||||
|
<div className="grid grid-cols-6 gap-1 p-2">
|
||||||
|
{filteredIcons.map((iconName) => {
|
||||||
|
const IconComponent = getIconComponent(iconName);
|
||||||
|
if (!IconComponent) return null;
|
||||||
|
|
||||||
|
const isSelected = selectedIcon === iconName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={iconName}
|
||||||
|
onClick={() => onSelectIcon(iconName)}
|
||||||
|
className={cn(
|
||||||
|
'aspect-square rounded-md flex items-center justify-center',
|
||||||
|
'transition-all duration-150',
|
||||||
|
'hover:bg-accent hover:scale-110',
|
||||||
|
isSelected
|
||||||
|
? 'bg-brand-500/20 border-2 border-brand-500'
|
||||||
|
: 'border border-transparent'
|
||||||
|
)}
|
||||||
|
title={iconName}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
className={cn('w-5 h-5', isSelected ? 'text-brand-500' : 'text-foreground')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className={cn(
|
||||||
|
'fixed z-[100] min-w-48 rounded-lg',
|
||||||
|
'bg-popover text-popover-foreground',
|
||||||
|
'border border-border shadow-lg',
|
||||||
|
'animate-in fade-in zoom-in-95 duration-100'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: position.y,
|
||||||
|
left: position.x,
|
||||||
|
}}
|
||||||
|
data-testid="project-context-menu"
|
||||||
|
>
|
||||||
|
<div className="p-1">
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||||
|
'text-sm font-medium text-left',
|
||||||
|
'hover:bg-accent transition-colors',
|
||||||
|
'focus:outline-none focus:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid="edit-project-button"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
<span>Edit Name & Icon</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||||
|
'text-sm font-medium text-left',
|
||||||
|
'text-destructive hover:bg-destructive/10',
|
||||||
|
'transition-colors',
|
||||||
|
'focus:outline-none focus:bg-destructive/10'
|
||||||
|
)}
|
||||||
|
data-testid="remove-project-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span>Move to Trash</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEditDialog && (
|
||||||
|
<EditProjectDialog
|
||||||
|
project={project}
|
||||||
|
open={showEditDialog}
|
||||||
|
onOpenChange={setShowEditDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, LucideIcon>)[project.icon];
|
||||||
|
}
|
||||||
|
return Folder;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconComponent = getIconComponent();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
className={cn(
|
||||||
|
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
|
||||||
|
'transition-all duration-200 ease-out',
|
||||||
|
isActive
|
||||||
|
? [
|
||||||
|
// Active: Premium gradient with glow
|
||||||
|
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||||
|
'border border-brand-500/30',
|
||||||
|
'shadow-md shadow-brand-500/10',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
// Inactive: Subtle hover state
|
||||||
|
'hover:bg-accent/50',
|
||||||
|
'border border-transparent hover:border-border/40',
|
||||||
|
'hover:shadow-sm',
|
||||||
|
],
|
||||||
|
'hover:scale-105 active:scale-95'
|
||||||
|
)}
|
||||||
|
title={project.name}
|
||||||
|
data-testid={`project-switcher-${project.id}`}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
className={cn(
|
||||||
|
'w-6 h-6 transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? 'text-brand-500 drop-shadow-sm'
|
||||||
|
: 'text-muted-foreground group-hover:text-brand-400 group-hover:scale-110'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tooltip on hover */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||||
|
'bg-popover text-popover-foreground text-xs font-medium',
|
||||||
|
'border border-border shadow-lg',
|
||||||
|
'opacity-0 group-hover:opacity-100',
|
||||||
|
'transition-all duration-200 whitespace-nowrap z-50',
|
||||||
|
'translate-x-1 group-hover:translate-x-0 pointer-events-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/ui/src/components/layout/project-switcher/index.ts
Normal file
1
apps/ui/src/components/layout/project-switcher/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectSwitcher } from './project-switcher';
|
||||||
@@ -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<Project | null>(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 (
|
||||||
|
<>
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 flex flex-col w-16 z-50 relative',
|
||||||
|
// Glass morphism background with gradient
|
||||||
|
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
||||||
|
// Premium border with subtle glow
|
||||||
|
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]'
|
||||||
|
)}
|
||||||
|
data-testid="project-switcher"
|
||||||
|
>
|
||||||
|
{/* Projects List */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-3 px-2 space-y-2">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectSwitcherItem
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
isActive={currentProject?.id === project.id}
|
||||||
|
onClick={() => handleProjectClick(project)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(project, e)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Project Button */}
|
||||||
|
<div className="p-2 border-t border-border/40">
|
||||||
|
<button
|
||||||
|
onClick={handleNewProject}
|
||||||
|
className={cn(
|
||||||
|
'w-full aspect-square rounded-xl flex items-center justify-center',
|
||||||
|
'transition-all duration-200 ease-out',
|
||||||
|
'text-muted-foreground hover:text-foreground',
|
||||||
|
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||||
|
'hover:shadow-sm hover:scale-105 active:scale-95'
|
||||||
|
)}
|
||||||
|
title="New Project"
|
||||||
|
data-testid="new-project-button"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Context Menu */}
|
||||||
|
{contextMenuProject && contextMenuPosition && (
|
||||||
|
<ProjectContextMenu
|
||||||
|
project={contextMenuProject}
|
||||||
|
position={contextMenuPosition}
|
||||||
|
onClose={handleCloseContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { darkThemes, lightThemes } from '@/config/theme-options';
|
||||||
import { cn } from '@/lib/utils';
|
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';
|
import type { Theme, Project } from '../shared/types';
|
||||||
|
|
||||||
interface AppearanceSectionProps {
|
interface AppearanceSectionProps {
|
||||||
@@ -16,10 +20,33 @@ export function AppearanceSection({
|
|||||||
currentProject,
|
currentProject,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
}: AppearanceSectionProps) {
|
}: AppearanceSectionProps) {
|
||||||
|
const { setProjectIcon, setProjectName } = useAppStore();
|
||||||
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
||||||
|
const [editingProject, setEditingProject] = useState(false);
|
||||||
|
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
|
||||||
|
const [projectIcon, setProjectIconLocal] = useState<string | null>(
|
||||||
|
(currentProject as any)?.icon || null
|
||||||
|
);
|
||||||
|
|
||||||
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -40,7 +67,68 @@ export function AppearanceSection({
|
|||||||
Customize the look and feel of your application.
|
Customize the look and feel of your application.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Project Details Section */}
|
||||||
|
{currentProject && (
|
||||||
|
<div className="space-y-4 pb-6 border-b border-border/50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-foreground font-medium">Project Details</Label>
|
||||||
|
{!editingProject && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingProject(true)}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingProject ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="project-name-settings">Project Name</Label>
|
||||||
|
<Input
|
||||||
|
id="project-name-settings"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => setProjectNameLocal(e.target.value)}
|
||||||
|
placeholder="Enter project name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Project Icon</Label>
|
||||||
|
<IconPicker
|
||||||
|
selectedIcon={projectIcon}
|
||||||
|
onSelectIcon={setProjectIconLocal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSaveProjectDetails} disabled={!projectName.trim()}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-accent/30 border border-border/50">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-foreground">{currentProject.name}</div>
|
||||||
|
<div className="text-muted-foreground text-xs mt-0.5">{currentProject.path}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-foreground font-medium">
|
<Label className="text-foreground font-medium">
|
||||||
|
|||||||
@@ -3104,6 +3104,7 @@ export interface Project {
|
|||||||
lastOpened?: string;
|
lastOpened?: string;
|
||||||
theme?: string; // Per-project theme override (uses ThemeMode from app-store)
|
theme?: string; // Per-project theme override (uses ThemeMode from app-store)
|
||||||
isFavorite?: boolean; // Pin project to top of dashboard
|
isFavorite?: boolean; // Pin project to top of dashboard
|
||||||
|
icon?: string; // Lucide icon name for project identification
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrashedProject extends Project {
|
export interface TrashedProject extends Project {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/rea
|
|||||||
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
|
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
|
import { ProjectSwitcher } from '@/components/layout/project-switcher';
|
||||||
import {
|
import {
|
||||||
FileBrowserProvider,
|
FileBrowserProvider,
|
||||||
useFileBrowser,
|
useFileBrowser,
|
||||||
@@ -803,6 +804,10 @@ function RootLayoutContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show project switcher on all app pages (not on dashboard, setup, or login)
|
||||||
|
const showProjectSwitcher =
|
||||||
|
!isDashboardRoute && !isSetupRoute && !isLoginRoute && !isLoggedOutRoute;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||||
@@ -813,6 +818,7 @@ function RootLayoutContent() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showProjectSwitcher && <ProjectSwitcher />}
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
{/* Mobile menu toggle button - only shows when sidebar is closed on mobile */}
|
{/* Mobile menu toggle button - only shows when sidebar is closed on mobile */}
|
||||||
{!sidebarOpen && (
|
{!sidebarOpen && (
|
||||||
|
|||||||
@@ -873,6 +873,8 @@ export interface AppActions {
|
|||||||
cycleNextProject: () => void; // Cycle forward through project history (E)
|
cycleNextProject: () => void; // Cycle forward through project history (E)
|
||||||
clearProjectHistory: () => void; // Clear history, keeping only current project
|
clearProjectHistory: () => void; // Clear history, keeping only current project
|
||||||
toggleProjectFavorite: (projectId: string) => void; // Toggle project favorite status
|
toggleProjectFavorite: (projectId: string) => void; // Toggle project favorite status
|
||||||
|
setProjectIcon: (projectId: string, icon: string | null) => void; // Set project icon (null to clear)
|
||||||
|
setProjectName: (projectId: string, name: string) => void; // Update project name
|
||||||
|
|
||||||
// View actions
|
// View actions
|
||||||
setCurrentView: (view: ViewMode) => void;
|
setCurrentView: (view: ViewMode) => void;
|
||||||
@@ -1560,6 +1562,38 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setProjectIcon: (projectId, icon) => {
|
||||||
|
const { projects, currentProject } = get();
|
||||||
|
const updatedProjects = projects.map((p) =>
|
||||||
|
p.id === projectId ? { ...p, icon: icon === null ? undefined : icon } : p
|
||||||
|
);
|
||||||
|
set({ projects: updatedProjects });
|
||||||
|
// Also update currentProject if it matches
|
||||||
|
if (currentProject?.id === projectId) {
|
||||||
|
set({
|
||||||
|
currentProject: {
|
||||||
|
...currentProject,
|
||||||
|
icon: icon === null ? undefined : icon,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setProjectName: (projectId, name) => {
|
||||||
|
const { projects, currentProject } = get();
|
||||||
|
const updatedProjects = projects.map((p) => (p.id === projectId ? { ...p, name } : p));
|
||||||
|
set({ projects: updatedProjects });
|
||||||
|
// Also update currentProject if it matches
|
||||||
|
if (currentProject?.id === projectId) {
|
||||||
|
set({
|
||||||
|
currentProject: {
|
||||||
|
...currentProject,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// View actions
|
// View actions
|
||||||
setCurrentView: (view) => set({ currentView: view }),
|
setCurrentView: (view) => set({ currentView: view }),
|
||||||
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
||||||
|
|||||||
@@ -293,6 +293,8 @@ export interface ProjectRef {
|
|||||||
theme?: string;
|
theme?: string;
|
||||||
/** Whether project is pinned to favorites on dashboard */
|
/** Whether project is pinned to favorites on dashboard */
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
/** Lucide icon name for project identification */
|
||||||
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user