mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +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)
|
- [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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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,
|
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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
34
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user