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

@@ -24,6 +24,7 @@ For complete details on contribution terms and rights assignment, please review
- [Development Setup](#development-setup) - [Development Setup](#development-setup)
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [Pull Request Process](#pull-request-process) - [Pull Request Process](#pull-request-process)
- [Branching Strategy (RC Branches)](#branching-strategy-rc-branches)
- [Branch Naming Convention](#branch-naming-convention) - [Branch Naming Convention](#branch-naming-convention)
- [Commit Message Format](#commit-message-format) - [Commit Message Format](#commit-message-format)
- [Submitting a Pull Request](#submitting-a-pull-request) - [Submitting a Pull Request](#submitting-a-pull-request)
@@ -186,6 +187,59 @@ automaker/
This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged. This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged.
### Branching Strategy (RC Branches)
Automaker uses **Release Candidate (RC) branches** for all development work. Understanding this workflow is essential before contributing.
**How it works:**
1. **All development happens on RC branches** - We maintain version-specific RC branches (e.g., `v0.10.0rc`, `v0.11.0rc`) where all active development occurs
2. **RC branches are eventually merged to main** - Once an RC branch is stable and ready for release, it gets merged into `main`
3. **Main branch is for releases only** - The `main` branch contains only released, stable code
**Before creating a PR:**
1. **Check for the latest RC branch** - Before starting work, check the repository for the current RC branch:
```bash
git fetch upstream
git branch -r | grep rc
```
2. **Base your work on the RC branch** - Create your feature branch from the latest RC branch, not from `main`:
```bash
# Find the latest RC branch (e.g., v0.11.0rc)
git checkout upstream/v0.11.0rc
git checkout -b feature/your-feature-name
```
3. **Target the RC branch in your PR** - When opening your pull request, set the base branch to the current RC branch, not `main`
**Example workflow:**
```bash
# 1. Fetch latest changes
git fetch upstream
# 2. Check for RC branches
git branch -r | grep rc
# Output: upstream/v0.11.0rc
# 3. Create your branch from the RC
git checkout -b feature/add-dark-mode upstream/v0.11.0rc
# 4. Make your changes and commit
git commit -m "feat: Add dark mode support"
# 5. Push to your fork
git push origin feature/add-dark-mode
# 6. Open PR targeting the RC branch (v0.11.0rc), NOT main
```
**Important:** PRs opened directly against `main` will be asked to retarget to the current RC branch.
### Branch Naming Convention ### Branch Naming Convention
We use a consistent branch naming pattern to keep our repository organized: We use a consistent branch naming pattern to keep our repository organized:
@@ -275,14 +329,14 @@ Follow these steps to submit your contribution:
#### 1. Prepare Your Changes #### 1. Prepare Your Changes
Ensure you've synced with the latest upstream changes: Ensure you've synced with the latest upstream changes from the RC branch:
```bash ```bash
# Fetch latest changes from upstream # Fetch latest changes from upstream
git fetch upstream git fetch upstream
# Rebase your branch on main (if needed) # Rebase your branch on the current RC branch (if needed)
git rebase upstream/main git rebase upstream/v0.11.0rc # Use the current RC branch name
``` ```
#### 2. Run Pre-submission Checks #### 2. Run Pre-submission Checks
@@ -314,18 +368,19 @@ git push origin feature/your-feature-name
1. Go to your fork on GitHub 1. Go to your fork on GitHub
2. Click "Compare & pull request" for your branch 2. Click "Compare & pull request" for your branch
3. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `main` 3. **Important:** Set the base repository to `AutoMaker-Org/automaker` and the base branch to the **current RC branch** (e.g., `v0.11.0rc`), not `main`
4. Fill out the PR template completely 4. Fill out the PR template completely
#### PR Requirements Checklist #### PR Requirements Checklist
Your PR should include: Your PR should include:
- [ ] **Targets the current RC branch** (not `main`) - see [Branching Strategy](#branching-strategy-rc-branches)
- [ ] **Clear title** describing the change (use conventional commit format) - [ ] **Clear title** describing the change (use conventional commit format)
- [ ] **Description** explaining what changed and why - [ ] **Description** explaining what changed and why
- [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456` - [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456`
- [ ] **All CI checks passing** (format, lint, build, tests) - [ ] **All CI checks passing** (format, lint, build, tests)
- [ ] **No merge conflicts** with main branch - [ ] **No merge conflicts** with the RC branch
- [ ] **Tests included** for new functionality - [ ] **Tests included** for new functionality
- [ ] **Documentation updated** if adding/changing public APIs - [ ] **Documentation updated** if adding/changing public APIs

View File

@@ -56,6 +56,7 @@
"@radix-ui/react-label": "2.1.8", "@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "2.2.6", "@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4", "@radix-ui/react-slot": "1.2.4",

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -9,7 +9,10 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Upload, X, ImageIcon } from 'lucide-react';
import { useAppStore } from '@/store/app-store'; 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 type { Project } from '@/lib/electron';
import { IconPicker } from './icon-picker'; import { IconPicker } from './icon-picker';
@@ -20,20 +23,75 @@ interface EditProjectDialogProps {
} }
export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) { export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) {
const { setProjectName, setProjectIcon } = useAppStore(); const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore();
const [name, setName] = useState(project.name); 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 = () => { const handleSave = () => {
if (name.trim() !== project.name) { if (name.trim() !== project.name) {
setProjectName(project.id, name.trim()); setProjectName(project.id, name.trim());
} }
if (icon !== project.icon) { if (icon !== (project as any).icon) {
setProjectIcon(project.id, icon); setProjectIcon(project.id, icon);
} }
if (customIconPath !== (project as any).customIconPath) {
setProjectCustomIcon(project.id, customIconPath);
}
onOpenChange(false); 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
@@ -41,7 +99,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
<DialogTitle>Edit Project</DialogTitle> <DialogTitle>Edit Project</DialogTitle>
</DialogHeader> </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 */} {/* Project Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label> <Label htmlFor="project-name">Project Name</Label>
@@ -56,11 +114,66 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
{/* Icon Picker */} {/* Icon Picker */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Project Icon</Label> <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>
</div> </div>
<DialogFooter> <DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>

View File

@@ -1,20 +1,24 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { Edit2, Trash2, ImageIcon } from 'lucide-react'; import { Edit2, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import { EditProjectDialog } from './edit-project-dialog';
interface ProjectContextMenuProps { interface ProjectContextMenuProps {
project: Project; project: Project;
position: { x: number; y: number }; position: { x: number; y: number };
onClose: () => void; 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 menuRef = useRef<HTMLDivElement>(null);
const { moveProjectToTrash } = useAppStore(); const { moveProjectToTrash } = useAppStore();
const [showEditDialog, setShowEditDialog] = useState(false);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@@ -39,72 +43,61 @@ export function ProjectContextMenu({ project, position, onClose }: ProjectContex
}, [onClose]); }, [onClose]);
const handleEdit = () => { const handleEdit = () => {
setShowEditDialog(true); onEdit(project);
onClose();
}; };
const handleRemove = () => { const handleRemove = () => {
if (confirm(`Move "${project.name}" to trash?`)) { if (confirm(`Remove "${project.name}" from the project list?`)) {
moveProjectToTrash(project.id); moveProjectToTrash(project.id);
} }
onClose(); onClose();
}; };
return ( return (
<> <div
<div ref={menuRef}
ref={menuRef} className={cn(
className={cn( 'fixed z-[100] min-w-48 rounded-lg',
'fixed z-[100] min-w-48 rounded-lg', 'bg-popover text-popover-foreground',
'bg-popover text-popover-foreground', 'border border-border shadow-lg',
'border border-border shadow-lg', 'animate-in fade-in zoom-in-95 duration-100'
'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}
/>
)} )}
</> 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 { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
interface ProjectSwitcherItemProps { interface ProjectSwitcherItemProps {
project: Project; project: Project;
isActive: boolean; isActive: boolean;
hotkeyIndex?: number; // 0-9 for hotkeys 1-9, 0
onClick: () => void; onClick: () => void;
onContextMenu: (event: React.MouseEvent) => void; onContextMenu: (event: React.MouseEvent) => void;
} }
@@ -13,9 +15,17 @@ interface ProjectSwitcherItemProps {
export function ProjectSwitcherItem({ export function ProjectSwitcherItem({
project, project,
isActive, isActive,
hotkeyIndex,
onClick, onClick,
onContextMenu, onContextMenu,
}: ProjectSwitcherItemProps) { }: 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 // Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => { const getIconComponent = (): LucideIcon => {
if (project.icon && project.icon in LucideIcons) { if (project.icon && project.icon in LucideIcons) {
@@ -25,6 +35,7 @@ export function ProjectSwitcherItem({
}; };
const IconComponent = getIconComponent(); const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
return ( return (
<button <button
@@ -51,14 +62,25 @@ export function ProjectSwitcherItem({
title={project.name} title={project.name}
data-testid={`project-switcher-${project.id}`} data-testid={`project-switcher-${project.id}`}
> >
<IconComponent {hasCustomIcon ? (
className={cn( <img
'w-6 h-6 transition-all duration-200', src={getAuthenticatedImageUrl(project.customIconPath!, project.path)}
isActive alt={project.name}
? 'text-brand-500 drop-shadow-sm' className={cn(
: 'text-muted-foreground group-hover:text-brand-400 group-hover:scale-110' '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 */} {/* Tooltip on hover */}
<span <span
@@ -73,6 +95,22 @@ export function ProjectSwitcherItem({
> >
{project.name} {project.name}
</span> </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> </button>
); );
} }

View File

@@ -1,19 +1,72 @@
import { useState } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { Plus } from 'lucide-react'; import { Plus, Bug } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu'; 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 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() { export function ProjectSwitcher() {
const navigate = useNavigate(); const navigate = useNavigate();
const { projects, currentProject, setCurrentProject } = useAppStore(); const {
projects,
currentProject,
setCurrentProject,
trashedProjects,
upsertAndSetCurrentProject,
} = useAppStore();
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null); const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
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) => { const handleContextMenu = (project: Project, event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
@@ -26,16 +79,70 @@ export function ProjectSwitcher() {
setContextMenuPosition(null); setContextMenuPosition(null);
}; };
const handleProjectClick = (project: Project) => { const handleEditProject = (project: Project) => {
setCurrentProject(project); setEditDialogProject(project);
// Navigate to board view when switching projects 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' }); navigate({ to: '/board' });
}; };
const handleNewProject = () => { const handleBugReportClick = useCallback(() => {
// Navigate to dashboard where users can create new projects const api = getElectronAPI();
navigate({ to: '/dashboard' }); 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 ( return (
<> <>
@@ -49,23 +156,110 @@ export function ProjectSwitcher() {
)} )}
data-testid="project-switcher" 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 */} {/* Projects List */}
<div className="flex-1 overflow-y-auto py-3 px-2 space-y-2"> <div className="flex-1 overflow-y-auto py-3 px-2 space-y-2">
{projects.map((project) => ( {projects.map((project, index) => (
<ProjectSwitcherItem <ProjectSwitcherItem
key={project.id} key={project.id}
project={project} project={project}
isActive={currentProject?.id === project.id} isActive={currentProject?.id === project.id}
hotkeyIndex={index < 10 ? index : undefined}
onClick={() => handleProjectClick(project)} onClick={() => handleProjectClick(project)}
onContextMenu={(e) => handleContextMenu(project, e)} 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> </div>
{/* Add Project Button */} {/* Bug Report Button at the very bottom */}
<div className="p-2 border-t border-border/40"> <div className="p-2 border-t border-border/40">
<button <button
onClick={handleNewProject} onClick={handleBugReportClick}
className={cn( className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center', 'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out', '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:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95' 'hover:shadow-sm hover:scale-105 active:scale-95'
)} )}
title="New Project" title="Report Bug / Feature Request"
data-testid="new-project-button" data-testid="bug-report-button"
> >
<Plus className="w-5 h-5" /> <Bug className="w-5 h-5" />
</button> </button>
</div> </div>
</aside> </aside>
@@ -87,8 +281,37 @@ export function ProjectSwitcher() {
project={contextMenuProject} project={contextMenuProject}
position={contextMenuPosition} position={contextMenuPosition}
onClose={handleCloseContextMenu} 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, CollapseToggleButton,
SidebarHeader, SidebarHeader,
SidebarNavigation, SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter, SidebarFooter,
} from './sidebar/components'; } from './sidebar/components';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
@@ -64,9 +63,6 @@ export function Sidebar() {
// Get customizable keyboard shortcuts // Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
// State for project picker (needed for keyboard shortcuts)
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
// State for delete project confirmation dialog // State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
@@ -240,7 +236,6 @@ export function Sidebar() {
navigate, navigate,
toggleSidebar, toggleSidebar,
handleOpenFolder, handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount, unviewedValidationsCount,
@@ -288,14 +283,7 @@ export function Sidebar() {
/> />
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} /> <SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} />
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarNavigation <SidebarNavigation
currentProject={currentProject} 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 { cn, isMac } from '@/lib/utils';
import { AutomakerLogo } from './automaker-logo'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { BugReportButton } from './bug-report-button'; import type { Project } from '@/lib/electron';
interface SidebarHeaderProps { interface SidebarHeaderProps {
sidebarOpen: boolean; sidebarOpen: boolean;
navigate: (opts: NavigateOptions) => void; currentProject: Project | null;
} }
export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) { export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) {
return ( // Get the icon component from lucide-react
<> const getIconComponent = (): LucideIcon => {
{/* Logo */} if (currentProject?.icon && currentProject.icon in LucideIcons) {
<div return (LucideIcons as Record<string, LucideIcon>)[currentProject.icon];
className={cn( }
'h-20 shrink-0 titlebar-drag-region', return Folder;
// 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>
{/* Bug Report Button - Collapsed sidebar version */} const IconComponent = getIconComponent();
{!sidebarOpen && ( const hasCustomIcon = !!currentProject?.customIconPath;
<div className="px-3 mt-1.5 flex justify-center">
<BugReportButton sidebarExpanded={false} /> 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>
)} )}
</> </div>
); );
} }

View File

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

View File

@@ -0,0 +1,73 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const ScrollAreaRootPrimitive = ScrollAreaPrimitive.Root as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaViewportPrimitive = ScrollAreaPrimitive.Viewport as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaScrollbarPrimitive =
ScrollAreaPrimitive.Scrollbar as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaThumbPrimitive = ScrollAreaPrimitive.Thumb as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Thumb> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaRootPrimitive
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaViewportPrimitive className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaViewportPrimitive>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaRootPrimitive>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaScrollbarPrimitive
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaThumbPrimitive className="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbarPrimitive>
));
ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -67,6 +67,8 @@ export function SettingsView() {
name: project.name, name: project.name,
path: project.path, path: project.path,
theme: project.theme as Theme | undefined, theme: project.theme as Theme | undefined,
icon: project.icon,
customIconPath: project.customIconPath,
}; };
}; };

View File

@@ -1,12 +1,14 @@
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Palette, Moon, Sun, Edit2 } from 'lucide-react'; import { Palette, Moon, Sun, Upload, X, ImageIcon } 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 { useAppStore } from '@/store/app-store';
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker'; import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Theme, Project } from '../shared/types'; import type { Theme, Project } from '../shared/types';
interface AppearanceSectionProps { interface AppearanceSectionProps {
@@ -20,31 +22,95 @@ export function AppearanceSection({
currentProject, currentProject,
onThemeChange, onThemeChange,
}: AppearanceSectionProps) { }: AppearanceSectionProps) {
const { setProjectIcon, setProjectName } = useAppStore(); const { setProjectIcon, setProjectName, setProjectCustomIcon } = 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 [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
const [projectIcon, setProjectIconLocal] = useState<string | null>( const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
(currentProject as any)?.icon || null const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
currentProject?.customIconPath || null
); );
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync local state when currentProject changes
useEffect(() => {
setProjectNameLocal(currentProject?.name || '');
setProjectIconLocal(currentProject?.icon || null);
setCustomIconPathLocal(currentProject?.customIconPath || null);
}, [currentProject]);
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes; const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
const handleSaveProjectDetails = () => { // Auto-save when values change
if (!currentProject) return; const handleNameChange = (name: string) => {
if (projectName.trim() !== currentProject.name) { setProjectNameLocal(name);
setProjectName(currentProject.id, projectName.trim()); if (currentProject && name.trim() && name.trim() !== currentProject.name) {
setProjectName(currentProject.id, name.trim());
} }
if (projectIcon !== (currentProject as any)?.icon) {
setProjectIcon(currentProject.id, projectIcon);
}
setEditingProject(false);
}; };
const handleCancelEdit = () => { const handleIconChange = (icon: string | null) => {
setProjectNameLocal(currentProject?.name || ''); setProjectIconLocal(icon);
setProjectIconLocal((currentProject as any)?.icon || null); if (currentProject) {
setEditingProject(false); setProjectIcon(currentProject.id, icon);
}
};
const handleCustomIconChange = (path: string | null) => {
setCustomIconPathLocal(path);
if (currentProject) {
setProjectCustomIcon(currentProject.id, path);
// Clear Lucide icon when custom icon is set
if (path) {
setProjectIconLocal(null);
setProjectIcon(currentProject.id, null);
}
}
};
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !currentProject) 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,
currentProject.path
);
if (result.success && result.path) {
handleCustomIconChange(result.path);
}
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
handleCustomIconChange(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}; };
return ( return (
@@ -71,60 +137,79 @@ export function AppearanceSection({
{/* Project Details Section */} {/* Project Details Section */}
{currentProject && ( {currentProject && (
<div className="space-y-4 pb-6 border-b border-border/50"> <div className="space-y-4 pb-6 border-b border-border/50">
<div className="flex items-center justify-between"> <div className="space-y-4">
<Label className="text-foreground font-medium">Project Details</Label> <div className="space-y-2">
{!editingProject && ( <Label htmlFor="project-name-settings">Project Name</Label>
<Button <Input
variant="ghost" id="project-name-settings"
size="sm" value={projectName}
onClick={() => setEditingProject(true)} onChange={(e) => handleNameChange(e.target.value)}
className="h-8" placeholder="Enter project name"
> />
<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>
) : (
<div className="space-y-3"> <div className="space-y-2">
<div className="flex items-center gap-3 p-3 rounded-lg bg-accent/30 border border-border/50"> <Label>Project Icon</Label>
<div className="text-sm"> <p className="text-xs text-muted-foreground mb-2">
<div className="font-medium text-foreground">{currentProject.name}</div> Choose a preset icon or upload a custom image
<div className="text-muted-foreground text-xs mt-0.5">{currentProject.path}</div> </p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, currentProject.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"
/>
<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>
</div> </div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && (
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
)}
</div> </div>
)} </div>
</div> </div>
)} )}

View File

@@ -26,6 +26,8 @@ export interface Project {
name: string; name: string;
path: string; path: string;
theme?: string; theme?: string;
icon?: string;
customIconPath?: string;
} }
export interface ApiKeys { export interface ApiKeys {

View File

@@ -533,6 +533,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
lastOpened: ref.lastOpened, lastOpened: ref.lastOpened,
theme: ref.theme, theme: ref.theme,
isFavorite: ref.isFavorite, isFavorite: ref.isFavorite,
icon: ref.icon,
customIconPath: ref.customIconPath,
features: [], // Features are loaded separately when project is opened features: [], // Features are loaded separately when project is opened
})); }));

View File

@@ -3105,6 +3105,7 @@ export interface Project {
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 icon?: string; // Lucide icon name for project identification
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/
} }
export interface TrashedProject extends Project { export interface TrashedProject extends Project {

View File

@@ -874,6 +874,7 @@ export interface AppActions {
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) setProjectIcon: (projectId: string, icon: string | null) => void; // Set project icon (null to clear)
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => void; // Set custom project icon image path (null to clear)
setProjectName: (projectId: string, name: string) => void; // Update project name setProjectName: (projectId: string, name: string) => void; // Update project name
// View actions // View actions
@@ -1579,6 +1580,25 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
} }
}, },
setProjectCustomIcon: (projectId, customIconPath) => {
const { projects, currentProject } = get();
const updatedProjects = projects.map((p) =>
p.id === projectId
? { ...p, customIconPath: customIconPath === null ? undefined : customIconPath }
: p
);
set({ projects: updatedProjects });
// Also update currentProject if it matches
if (currentProject?.id === projectId) {
set({
currentProject: {
...currentProject,
customIconPath: customIconPath === null ? undefined : customIconPath,
},
});
}
},
setProjectName: (projectId, name) => { setProjectName: (projectId, name) => {
const { projects, currentProject } = get(); const { projects, currentProject } = get();
const updatedProjects = projects.map((p) => (p.id === projectId ? { ...p, name } : p)); const updatedProjects = projects.map((p) => (p.id === projectId ? { ...p, name } : p));

View File

@@ -295,6 +295,8 @@ export interface ProjectRef {
isFavorite?: boolean; isFavorite?: boolean;
/** Lucide icon name for project identification */ /** Lucide icon name for project identification */
icon?: string; icon?: string;
/** Custom icon image path for project switcher */
customIconPath?: string;
} }
/** /**
@@ -600,6 +602,10 @@ export interface ProjectSettings {
/** Project-specific board background settings */ /** Project-specific board background settings */
boardBackground?: BoardBackgroundSettings; boardBackground?: BoardBackgroundSettings;
// Project Branding
/** Custom icon image path for project switcher (relative to .automaker/) */
customIconPath?: string;
// UI Visibility // UI Visibility
/** Whether the worktree panel row is visible (default: true) */ /** Whether the worktree panel row is visible (default: true) */
worktreePanelVisible?: boolean; worktreePanelVisible?: boolean;

34
package-lock.json generated
View File

@@ -101,6 +101,7 @@
"@radix-ui/react-label": "2.1.8", "@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "2.2.6", "@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4", "@radix-ui/react-slot": "1.2.4",
@@ -1479,7 +1480,7 @@
}, },
"node_modules/@electron/node-gyp": { "node_modules/@electron/node-gyp": {
"version": "10.2.0-electron.1", "version": "10.2.0-electron.1",
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -4744,6 +4745,37 @@
} }
} }
}, },
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": { "node_modules/@radix-ui/react-select": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",