feat: enhance project management with custom icon support and UI improvements

- Introduced custom icon functionality for projects, allowing users to upload and manage their own icons.
- Updated Project and ProjectRef types to include customIconPath.
- Enhanced the ProjectSwitcher component to display custom icons alongside preset icons.
- Added EditProjectDialog for inline editing of project details, including icon uploads.
- Improved AppearanceSection to support custom icon uploads and display.
- Updated sidebar and project switcher UI for better user experience and accessibility.

Implements #469
This commit is contained in:
webdevcody
2026-01-13 14:39:19 -05:00
parent 0ddd672e0e
commit a300466ca9
18 changed files with 867 additions and 224 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -9,7 +9,10 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Upload, X, ImageIcon } from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { IconPicker } from './icon-picker';
@@ -20,20 +23,75 @@ interface EditProjectDialogProps {
}
export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) {
const { setProjectName, setProjectIcon } = useAppStore();
const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore();
const [name, setName] = useState(project.name);
const [icon, setIcon] = useState<string | null>(project.icon || null);
const [icon, setIcon] = useState<string | null>((project as any).icon || null);
const [customIconPath, setCustomIconPath] = useState<string | null>(
(project as any).customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSave = () => {
if (name.trim() !== project.name) {
setProjectName(project.id, name.trim());
}
if (icon !== project.icon) {
if (icon !== (project as any).icon) {
setProjectIcon(project.id, icon);
}
if (customIconPath !== (project as any).customIconPath) {
setProjectCustomIcon(project.id, customIconPath);
}
onOpenChange(false);
};
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
return;
}
setIsUploadingIcon(true);
try {
// Convert to base64
const reader = new FileReader();
reader.onload = async () => {
const base64Data = reader.result as string;
const result = await getHttpApiClient().saveImageToTemp(
base64Data,
`project-icon-${file.name}`,
file.type,
project.path
);
if (result.success && result.path) {
setCustomIconPath(result.path);
// Clear the Lucide icon when custom icon is set
setIcon(null);
}
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
setCustomIconPath(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
@@ -41,7 +99,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
<DialogTitle>Edit Project</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
@@ -56,11 +114,66 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
{/* Icon Picker */}
<div className="space-y-2">
<Label>Project Icon</Label>
<IconPicker selectedIcon={icon} onSelectIcon={setIcon} />
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, project.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload-dialog"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && <IconPicker selectedIcon={icon} onSelectIcon={setIcon} />}
</div>
</div>
<DialogFooter>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>

View File

@@ -1,20 +1,24 @@
import { useEffect, useRef, useState } from 'react';
import { Edit2, Trash2, ImageIcon } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { Edit2, Trash2 } 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;
onEdit: (project: Project) => void;
}
export function ProjectContextMenu({ project, position, onClose }: ProjectContextMenuProps) {
export function ProjectContextMenu({
project,
position,
onClose,
onEdit,
}: ProjectContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const { moveProjectToTrash } = useAppStore();
const [showEditDialog, setShowEditDialog] = useState(false);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -39,72 +43,61 @@ export function ProjectContextMenu({ project, position, onClose }: ProjectContex
}, [onClose]);
const handleEdit = () => {
setShowEditDialog(true);
onClose();
onEdit(project);
};
const handleRemove = () => {
if (confirm(`Move "${project.name}" to trash?`)) {
if (confirm(`Remove "${project.name}" from the project list?`)) {
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}
/>
<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>Remove Project</span>
</button>
</div>
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron';
interface ProjectSwitcherItemProps {
project: Project;
isActive: boolean;
hotkeyIndex?: number; // 0-9 for hotkeys 1-9, 0
onClick: () => void;
onContextMenu: (event: React.MouseEvent) => void;
}
@@ -13,9 +15,17 @@ interface ProjectSwitcherItemProps {
export function ProjectSwitcherItem({
project,
isActive,
hotkeyIndex,
onClick,
onContextMenu,
}: ProjectSwitcherItemProps) {
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
const hotkeyLabel =
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
? hotkeyIndex === 9
? '0'
: String(hotkeyIndex + 1)
: undefined;
// Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => {
if (project.icon && project.icon in LucideIcons) {
@@ -25,6 +35,7 @@ export function ProjectSwitcherItem({
};
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
return (
<button
@@ -51,14 +62,25 @@ export function ProjectSwitcherItem({
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'
)}
/>
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(project.customIconPath!, project.path)}
alt={project.name}
className={cn(
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
)}
/>
) : (
<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
@@ -73,6 +95,22 @@ export function ProjectSwitcherItem({
>
{project.name}
</span>
{/* Hotkey badge */}
{hotkeyLabel && (
<span
className={cn(
'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 px-1',
'flex items-center justify-center',
'text-[10px] font-medium rounded',
'bg-muted/80 text-muted-foreground',
'border border-border/50',
'pointer-events-none'
)}
>
{hotkeyLabel}
</span>
)}
</button>
);
}

View File

@@ -1,19 +1,72 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu';
import { EditProjectDialog } from './components/edit-project-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
function getOSAbbreviation(os: string): string {
switch (os) {
case 'mac':
return 'M';
case 'windows':
return 'W';
case 'linux':
return 'L';
default:
return '?';
}
}
export function ProjectSwitcher() {
const navigate = useNavigate();
const { projects, currentProject, setCurrentProject } = useAppStore();
const {
projects,
currentProject,
setCurrentProject,
trashedProjects,
upsertAndSetCurrentProject,
} = useAppStore();
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
null
);
const [editDialogProject, setEditDialogProject] = useState<Project | null>(null);
// Version info
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
const { os } = useOSDetection();
const appMode = import.meta.env.VITE_APP_MODE || '?';
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
// Get global theme for project creation
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
setShowNewProjectModal,
isCreatingProject,
showOnboardingDialog,
setShowOnboardingDialog,
newProjectName,
handleCreateBlankProject,
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
const handleContextMenu = (project: Project, event: React.MouseEvent) => {
event.preventDefault();
@@ -26,16 +79,70 @@ export function ProjectSwitcher() {
setContextMenuPosition(null);
};
const handleProjectClick = (project: Project) => {
setCurrentProject(project);
// Navigate to board view when switching projects
const handleEditProject = (project: Project) => {
setEditDialogProject(project);
handleCloseContextMenu();
};
const handleProjectClick = useCallback(
(project: Project) => {
setCurrentProject(project);
// Navigate to board view when switching projects
navigate({ to: '/board' });
},
[setCurrentProject, navigate]
);
const handleNewProject = () => {
// Open the new project modal
setShowNewProjectModal(true);
};
const handleOnboardingSkip = () => {
setShowOnboardingDialog(false);
navigate({ to: '/board' });
};
const handleNewProject = () => {
// Navigate to dashboard where users can create new projects
navigate({ to: '/dashboard' });
};
const handleBugReportClick = useCallback(() => {
const api = getElectronAPI();
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
}, []);
// Keyboard shortcuts for project switching (1-9, 0)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Ignore if user is typing in an input, textarea, or contenteditable
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Ignore if modifier keys are pressed (except for standalone number keys)
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
// Map key to project index: "1" -> 0, "2" -> 1, ..., "9" -> 8, "0" -> 9
const key = event.key;
let projectIndex: number | null = null;
if (key >= '1' && key <= '9') {
projectIndex = parseInt(key, 10) - 1; // "1" -> 0, "9" -> 8
} else if (key === '0') {
projectIndex = 9; // "0" -> 9
}
if (projectIndex !== null && projectIndex < projects.length) {
const targetProject = projects[projectIndex];
if (targetProject && targetProject.id !== currentProject?.id) {
handleProjectClick(targetProject);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [projects, currentProject, handleProjectClick]);
return (
<>
@@ -49,23 +156,110 @@ export function ProjectSwitcher() {
)}
data-testid="project-switcher"
>
{/* Automaker Logo and Version */}
<div className="flex flex-col items-center pt-3 pb-2 px-2">
<button
onClick={() => navigate({ to: '/dashboard' })}
className="group flex flex-col items-center gap-0.5"
title="Go to Dashboard"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-switcher"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-switcher)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
v{appVersion} {versionSuffix}
</span>
</button>
<div className="w-full h-px bg-border mt-3" />
</div>
{/* Projects List */}
<div className="flex-1 overflow-y-auto py-3 px-2 space-y-2">
{projects.map((project) => (
{projects.map((project, index) => (
<ProjectSwitcherItem
key={project.id}
project={project}
isActive={currentProject?.id === project.id}
hotkeyIndex={index < 10 ? index : undefined}
onClick={() => handleProjectClick(project)}
onContextMenu={(e) => handleContextMenu(project, e)}
/>
))}
{/* Horizontal rule and Add Project Button - only show if there are projects */}
{projects.length > 0 && (
<>
<div className="w-full h-px bg-border/40 my-2" />
<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>
</>
)}
{/* Add Project Button - when no projects, show without rule */}
{projects.length === 0 && (
<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>
{/* Add Project Button */}
{/* Bug Report Button at the very bottom */}
<div className="p-2 border-t border-border/40">
<button
onClick={handleNewProject}
onClick={handleBugReportClick}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
@@ -73,10 +267,10 @@ export function ProjectSwitcher() {
'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"
title="Report Bug / Feature Request"
data-testid="bug-report-button"
>
<Plus className="w-5 h-5" />
<Bug className="w-5 h-5" />
</button>
</div>
</aside>
@@ -87,8 +281,37 @@ export function ProjectSwitcher() {
project={contextMenuProject}
position={contextMenuPosition}
onClose={handleCloseContextMenu}
onEdit={handleEditProject}
/>
)}
{/* Edit Project Dialog */}
{editDialogProject && (
<EditProjectDialog
project={editDialogProject}
open={!!editDialogProject}
onOpenChange={(open) => !open && setEditDialogProject(null)}
/>
)}
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
{/* Onboarding Dialog */}
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingSkip}
/>
</>
);
}

View File

@@ -18,7 +18,6 @@ import {
CollapseToggleButton,
SidebarHeader,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
@@ -64,9 +63,6 @@ export function Sidebar() {
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// State for project picker (needed for keyboard shortcuts)
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
@@ -240,7 +236,6 @@ export function Sidebar() {
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
@@ -288,14 +283,7 @@ export function Sidebar() {
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} />
<SidebarNavigation
currentProject={currentProject}

View File

@@ -1,43 +1,64 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn, isMac } from '@/lib/utils';
import { AutomakerLogo } from './automaker-logo';
import { BugReportButton } from './bug-report-button';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron';
interface SidebarHeaderProps {
sidebarOpen: boolean;
navigate: (opts: NavigateOptions) => void;
currentProject: Project | null;
}
export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
return (
<>
{/* Logo */}
<div
className={cn(
'h-20 shrink-0 titlebar-drag-region',
// Subtle bottom border with gradient fade
'border-b border-border/40',
// Background gradient for depth
'bg-gradient-to-b from-transparent to-background/5',
'flex items-center',
sidebarOpen ? 'px-4 lg:px-5 justify-start' : 'px-3 justify-center',
// Add padding on macOS to avoid overlapping with traffic light buttons
isMac && sidebarOpen && 'pt-4',
// Smaller top padding on macOS when collapsed
isMac && !sidebarOpen && 'pt-4'
)}
>
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Bug Report Button - Inside logo container when expanded */}
{sidebarOpen && <BugReportButton sidebarExpanded />}
</div>
export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) {
// Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as Record<string, LucideIcon>)[currentProject.icon];
}
return Folder;
};
{/* Bug Report Button - Collapsed sidebar version */}
{!sidebarOpen && (
<div className="px-3 mt-1.5 flex justify-center">
<BugReportButton sidebarExpanded={false} />
const IconComponent = getIconComponent();
const hasCustomIcon = !!currentProject?.customIconPath;
return (
<div
className={cn(
'shrink-0 flex flex-col',
// Add minimal padding on macOS for traffic light buttons
isMac && 'pt-2'
)}
>
{/* Project name and icon display */}
{currentProject && (
<div
className={cn('flex items-center gap-3 px-4 py-3', !sidebarOpen && 'justify-center px-2')}
>
{/* Project Icon */}
<div className="shrink-0">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
alt={currentProject.name}
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<IconComponent className="w-5 h-5 text-brand-500" />
</div>
)}
</div>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
)}
</div>
)}
</>
</div>
);
}

View File

@@ -45,7 +45,6 @@ interface UseNavigationProps {
navigate: (opts: NavigateOptions) => void;
toggleSidebar: () => void;
handleOpenFolder: () => void;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
cyclePrevProject: () => void;
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
@@ -65,7 +64,6 @@ export function useNavigation({
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
@@ -230,15 +228,6 @@ export function useNavigation({
description: 'Open folder selection dialog',
});
// Project picker shortcut - only when we have projects
if (projects.length > 0) {
shortcutsList.push({
key: shortcuts.projectPicker,
action: () => setIsProjectPickerOpen((prev) => !prev),
description: 'Toggle project picker',
});
}
// Project cycling shortcuts - only when we have project history
if (projectHistory.length > 1) {
shortcutsList.push({
@@ -288,7 +277,6 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
navSections,
setIsProjectPickerOpen,
]);
return {