diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3fed705..61ad83f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/apps/ui/package.json b/apps/ui/package.json index 61bd5ae8..384dc581 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -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", diff --git a/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx new file mode 100644 index 00000000..31e39367 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx @@ -0,0 +1,187 @@ +import { useState, useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { 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'; + +interface EditProjectDialogProps { + project: Project; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) { + const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore(); + const [name, setName] = useState(project.name); + const [icon, setIcon] = useState((project as any).icon || null); + const [customIconPath, setCustomIconPath] = useState( + (project as any).customIconPath || null + ); + const [isUploadingIcon, setIsUploadingIcon] = useState(false); + const fileInputRef = useRef(null); + + const handleSave = () => { + if (name.trim() !== project.name) { + setProjectName(project.id, name.trim()); + } + 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) => { + 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 ( + + + + Edit Project + + +
+ {/* Project Name */} +
+ + setName(e.target.value)} + placeholder="Enter project name" + /> +
+ + {/* Icon Picker */} +
+ +

+ Choose a preset icon or upload a custom image +

+ + {/* Custom Icon Upload */} +
+
+ {customIconPath ? ( +
+ Custom project icon + +
+ ) : ( +
+ +
+ )} +
+ + +

+ PNG, JPG, GIF or WebP. Max 2MB. +

+
+
+
+ + {/* Preset Icon Picker - only show if no custom icon */} + {!customIconPath && } +
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx new file mode 100644 index 00000000..10947a51 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import { X, Search } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface IconPickerProps { + selectedIcon: string | null; + onSelectIcon: (icon: string | null) => void; +} + +// Popular project-related icons +const POPULAR_ICONS = [ + 'Folder', + 'FolderOpen', + 'FolderCode', + 'FolderGit', + 'FolderKanban', + 'Package', + 'Box', + 'Boxes', + 'Code', + 'Code2', + 'Braces', + 'FileCode', + 'Terminal', + 'Globe', + 'Server', + 'Database', + 'Layout', + 'Layers', + 'Blocks', + 'Component', + 'Puzzle', + 'Cog', + 'Wrench', + 'Hammer', + 'Zap', + 'Rocket', + 'Sparkles', + 'Star', + 'Heart', + 'Shield', + 'Lock', + 'Key', + 'Cpu', + 'CircuitBoard', + 'Workflow', +]; + +export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) { + const [search, setSearch] = useState(''); + + const filteredIcons = POPULAR_ICONS.filter((icon) => + icon.toLowerCase().includes(search.toLowerCase()) + ); + + const getIconComponent = (iconName: string) => { + return (LucideIcons as Record>)[iconName]; + }; + + return ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search icons..." + className="pl-9" + /> +
+ + {/* Selected Icon Display */} + {selectedIcon && ( +
+
+ {(() => { + const IconComponent = getIconComponent(selectedIcon); + return IconComponent ? : null; + })()} + {selectedIcon} +
+ +
+ )} + + {/* Icons Grid */} + +
+ {filteredIcons.map((iconName) => { + const IconComponent = getIconComponent(iconName); + if (!IconComponent) return null; + + const isSelected = selectedIcon === iconName; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/index.ts b/apps/ui/src/components/layout/project-switcher/components/index.ts new file mode 100644 index 00000000..86073ca1 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/index.ts @@ -0,0 +1,4 @@ +export { ProjectSwitcherItem } from './project-switcher-item'; +export { ProjectContextMenu } from './project-context-menu'; +export { EditProjectDialog } from './edit-project-dialog'; +export { IconPicker } from './icon-picker'; diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx new file mode 100644 index 00000000..84b6ea9a --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -0,0 +1,103 @@ +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'; + +interface ProjectContextMenuProps { + project: Project; + position: { x: number; y: number }; + onClose: () => void; + onEdit: (project: Project) => void; +} + +export function ProjectContextMenu({ + project, + position, + onClose, + onEdit, +}: ProjectContextMenuProps) { + const menuRef = useRef(null); + const { moveProjectToTrash } = useAppStore(); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [onClose]); + + const handleEdit = () => { + onEdit(project); + }; + + const handleRemove = () => { + if (confirm(`Remove "${project.name}" from the project list?`)) { + moveProjectToTrash(project.id); + } + onClose(); + }; + + return ( +
+
+ + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx new file mode 100644 index 00000000..b4434f8b --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx @@ -0,0 +1,116 @@ +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; +} + +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) { + return (LucideIcons as Record)[project.icon]; + } + return Folder; + }; + + const IconComponent = getIconComponent(); + const hasCustomIcon = !!project.customIconPath; + + return ( + + ); +} diff --git a/apps/ui/src/components/layout/project-switcher/index.ts b/apps/ui/src/components/layout/project-switcher/index.ts new file mode 100644 index 00000000..f540a4f6 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/index.ts @@ -0,0 +1 @@ +export { ProjectSwitcher } from './project-switcher'; diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx new file mode 100644 index 00000000..e6080ab4 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -0,0 +1,317 @@ +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, + trashedProjects, + upsertAndSetCurrentProject, + } = useAppStore(); + const [contextMenuProject, setContextMenuProject] = useState(null); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( + null + ); + const [editDialogProject, setEditDialogProject] = useState(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(); + setContextMenuProject(project); + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + }; + + const handleCloseContextMenu = () => { + setContextMenuProject(null); + setContextMenuPosition(null); + }; + + 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 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 ( + <> + + + {/* Context Menu */} + {contextMenuProject && contextMenuPosition && ( + + )} + + {/* Edit Project Dialog */} + {editDialogProject && ( + !open && setEditDialogProject(null)} + /> + )} + + {/* New Project Modal */} + + + {/* Onboarding Dialog */} + + + ); +} diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index a8c70cb6..613b113f 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -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() { />
- - - + void; + currentProject: Project | null; } -export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) { - return ( - <> - {/* Logo */} -
- - {/* Bug Report Button - Inside logo container when expanded */} - {sidebarOpen && } -
+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)[currentProject.icon]; + } + return Folder; + }; - {/* Bug Report Button - Collapsed sidebar version */} - {!sidebarOpen && ( -
- + const IconComponent = getIconComponent(); + const hasCustomIcon = !!currentProject?.customIconPath; + + return ( +
+ {/* Project name and icon display */} + {currentProject && ( +
+ {/* Project Icon */} +
+ {hasCustomIcon ? ( + {currentProject.name} + ) : ( +
+ +
+ )} +
+ + {/* Project Name - only show when sidebar is open */} + {sidebarOpen && ( +
+

+ {currentProject.name} +

+
+ )}
)} - +
); } diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 085606ae..110fa26c 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -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 { diff --git a/apps/ui/src/components/ui/scroll-area.tsx b/apps/ui/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..d3dd5cd2 --- /dev/null +++ b/apps/ui/src/components/ui/scroll-area.tsx @@ -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 & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const ScrollAreaViewportPrimitive = ScrollAreaPrimitive.Viewport as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const ScrollAreaScrollbarPrimitive = + ScrollAreaPrimitive.Scrollbar as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes + >; + +const ScrollAreaThumbPrimitive = ScrollAreaPrimitive.Thumb as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + +const ScrollArea = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 2655e8a5..8238476c 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -69,6 +69,8 @@ export function SettingsView() { name: project.name, path: project.path, theme: project.theme as Theme | undefined, + icon: project.icon, + customIconPath: project.customIconPath, }; }; diff --git a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx index 7f9ce3a6..003501f9 100644 --- a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx +++ b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx @@ -1,8 +1,14 @@ -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Label } from '@/components/ui/label'; -import { Palette, Moon, Sun } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Palette, Moon, Sun, 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 { @@ -16,10 +22,97 @@ export function AppearanceSection({ currentProject, onThemeChange, }: AppearanceSectionProps) { + const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore(); const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark'); + const [projectName, setProjectNameLocal] = useState(currentProject?.name || ''); + const [projectIcon, setProjectIconLocal] = useState(currentProject?.icon || null); + const [customIconPath, setCustomIconPathLocal] = useState( + currentProject?.customIconPath || null + ); + const [isUploadingIcon, setIsUploadingIcon] = useState(false); + const fileInputRef = useRef(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; + // Auto-save when values change + const handleNameChange = (name: string) => { + setProjectNameLocal(name); + if (currentProject && name.trim() && name.trim() !== currentProject.name) { + setProjectName(currentProject.id, name.trim()); + } + }; + + 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) => { + 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 (
-
+
+ {/* Project Details Section */} + {currentProject && ( +
+
+
+ + handleNameChange(e.target.value)} + placeholder="Enter project name" + /> +
+ +
+ +

+ Choose a preset icon or upload a custom image +

+ + {/* Custom Icon Upload */} +
+
+ {customIconPath ? ( +
+ Custom project icon + +
+ ) : ( +
+ +
+ )} +
+ + +

+ PNG, JPG, GIF or WebP. Max 2MB. +

+
+
+
+ + {/* Preset Icon Picker - only show if no custom icon */} + {!customIconPath && ( + + )} +
+
+
+ )} + + {/* Theme Section */}