mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-29 22:02:02 +00:00
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:
@@ -24,6 +24,7 @@ For complete details on contribution terms and rights assignment, please review
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Branching Strategy (RC Branches)](#branching-strategy-rc-branches)
|
||||
- [Branch Naming Convention](#branch-naming-convention)
|
||||
- [Commit Message Format](#commit-message-format)
|
||||
- [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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
Ensure you've synced with the latest upstream changes:
|
||||
Ensure you've synced with the latest upstream changes from the RC branch:
|
||||
|
||||
```bash
|
||||
# Fetch latest changes from upstream
|
||||
git fetch upstream
|
||||
|
||||
# Rebase your branch on main (if needed)
|
||||
git rebase upstream/main
|
||||
# Rebase your branch on the current RC branch (if needed)
|
||||
git rebase upstream/v0.11.0rc # Use the current RC branch name
|
||||
```
|
||||
|
||||
#### 2. Run Pre-submission Checks
|
||||
@@ -314,18 +368,19 @@ git push origin feature/your-feature-name
|
||||
|
||||
1. Go to your fork on GitHub
|
||||
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
|
||||
|
||||
#### PR Requirements Checklist
|
||||
|
||||
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)
|
||||
- [ ] **Description** explaining what changed and why
|
||||
- [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456`
|
||||
- [ ] **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
|
||||
- [ ] **Documentation updated** if adding/changing public APIs
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@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-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
73
apps/ui/src/components/ui/scroll-area.tsx
Normal file
73
apps/ui/src/components/ui/scroll-area.tsx
Normal 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 };
|
||||
@@ -67,6 +67,8 @@ export function SettingsView() {
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
theme: project.theme as Theme | undefined,
|
||||
icon: project.icon,
|
||||
customIconPath: project.customIconPath,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
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';
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
@@ -20,31 +22,95 @@ export function AppearanceSection({
|
||||
currentProject,
|
||||
onThemeChange,
|
||||
}: AppearanceSectionProps) {
|
||||
const { setProjectIcon, setProjectName } = useAppStore();
|
||||
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
|
||||
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 [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.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 handleSaveProjectDetails = () => {
|
||||
if (!currentProject) return;
|
||||
if (projectName.trim() !== currentProject.name) {
|
||||
setProjectName(currentProject.id, projectName.trim());
|
||||
// Auto-save when values change
|
||||
const handleNameChange = (name: string) => {
|
||||
setProjectNameLocal(name);
|
||||
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 = () => {
|
||||
setProjectNameLocal(currentProject?.name || '');
|
||||
setProjectIconLocal((currentProject as any)?.icon || null);
|
||||
setEditingProject(false);
|
||||
const handleIconChange = (icon: string | null) => {
|
||||
setProjectIconLocal(icon);
|
||||
if (currentProject) {
|
||||
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 (
|
||||
@@ -71,60 +137,79 @@ export function AppearanceSection({
|
||||
{/* 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 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) => handleNameChange(e.target.value)}
|
||||
placeholder="Enter project name"
|
||||
/>
|
||||
</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 className="space-y-2">
|
||||
<Label>Project Icon</Label>
|
||||
<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, 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>
|
||||
|
||||
{/* Preset Icon Picker - only show if no custom icon */}
|
||||
{!customIconPath && (
|
||||
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Project {
|
||||
name: string;
|
||||
path: string;
|
||||
theme?: string;
|
||||
icon?: string;
|
||||
customIconPath?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeys {
|
||||
|
||||
@@ -533,6 +533,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
lastOpened: ref.lastOpened,
|
||||
theme: ref.theme,
|
||||
isFavorite: ref.isFavorite,
|
||||
icon: ref.icon,
|
||||
customIconPath: ref.customIconPath,
|
||||
features: [], // Features are loaded separately when project is opened
|
||||
}));
|
||||
|
||||
|
||||
@@ -3105,6 +3105,7 @@ export interface Project {
|
||||
theme?: string; // Per-project theme override (uses ThemeMode from app-store)
|
||||
isFavorite?: boolean; // Pin project to top of dashboard
|
||||
icon?: string; // Lucide icon name for project identification
|
||||
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/
|
||||
}
|
||||
|
||||
export interface TrashedProject extends Project {
|
||||
|
||||
@@ -874,6 +874,7 @@ export interface AppActions {
|
||||
clearProjectHistory: () => void; // Clear history, keeping only current project
|
||||
toggleProjectFavorite: (projectId: string) => void; // Toggle project favorite status
|
||||
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
|
||||
|
||||
// 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) => {
|
||||
const { projects, currentProject } = get();
|
||||
const updatedProjects = projects.map((p) => (p.id === projectId ? { ...p, name } : p));
|
||||
|
||||
@@ -295,6 +295,8 @@ export interface ProjectRef {
|
||||
isFavorite?: boolean;
|
||||
/** Lucide icon name for project identification */
|
||||
icon?: string;
|
||||
/** Custom icon image path for project switcher */
|
||||
customIconPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -600,6 +602,10 @@ export interface ProjectSettings {
|
||||
/** Project-specific board background settings */
|
||||
boardBackground?: BoardBackgroundSettings;
|
||||
|
||||
// Project Branding
|
||||
/** Custom icon image path for project switcher (relative to .automaker/) */
|
||||
customIconPath?: string;
|
||||
|
||||
// UI Visibility
|
||||
/** Whether the worktree panel row is visible (default: true) */
|
||||
worktreePanelVisible?: boolean;
|
||||
|
||||
34
package-lock.json
generated
34
package-lock.json
generated
@@ -101,6 +101,7 @@
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@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-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
},
|
||||
"node_modules/@electron/node-gyp": {
|
||||
"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==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
|
||||
Reference in New Issue
Block a user