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

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

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

View File

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

View File

@@ -1,12 +1,14 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Palette, Moon, Sun, Edit2 } from 'lucide-react';
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Theme, Project } from '../shared/types';
interface AppearanceSectionProps {
@@ -20,31 +22,95 @@ export function AppearanceSection({
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
const { setProjectIcon, setProjectName } = useAppStore();
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const [editingProject, setEditingProject] = useState(false);
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
const [projectIcon, setProjectIconLocal] = useState<string | null>(
(currentProject as any)?.icon || null
const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
currentProject?.customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync local state when currentProject changes
useEffect(() => {
setProjectNameLocal(currentProject?.name || '');
setProjectIconLocal(currentProject?.icon || null);
setCustomIconPathLocal(currentProject?.customIconPath || null);
}, [currentProject]);
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
const handleSaveProjectDetails = () => {
if (!currentProject) return;
if (projectName.trim() !== currentProject.name) {
setProjectName(currentProject.id, projectName.trim());
// Auto-save when values change
const handleNameChange = (name: string) => {
setProjectNameLocal(name);
if (currentProject && name.trim() && name.trim() !== currentProject.name) {
setProjectName(currentProject.id, name.trim());
}
if (projectIcon !== (currentProject as any)?.icon) {
setProjectIcon(currentProject.id, projectIcon);
}
setEditingProject(false);
};
const handleCancelEdit = () => {
setProjectNameLocal(currentProject?.name || '');
setProjectIconLocal((currentProject as any)?.icon || null);
setEditingProject(false);
const handleIconChange = (icon: string | null) => {
setProjectIconLocal(icon);
if (currentProject) {
setProjectIcon(currentProject.id, icon);
}
};
const handleCustomIconChange = (path: string | null) => {
setCustomIconPathLocal(path);
if (currentProject) {
setProjectCustomIcon(currentProject.id, path);
// Clear Lucide icon when custom icon is set
if (path) {
setProjectIconLocal(null);
setProjectIcon(currentProject.id, null);
}
}
};
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !currentProject) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
return;
}
setIsUploadingIcon(true);
try {
// Convert to base64
const reader = new FileReader();
reader.onload = async () => {
const base64Data = reader.result as string;
const result = await getHttpApiClient().saveImageToTemp(
base64Data,
`project-icon-${file.name}`,
file.type,
currentProject.path
);
if (result.success && result.path) {
handleCustomIconChange(result.path);
}
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
handleCustomIconChange(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
@@ -71,60 +137,79 @@ export function AppearanceSection({
{/* Project Details Section */}
{currentProject && (
<div className="space-y-4 pb-6 border-b border-border/50">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Project Details</Label>
{!editingProject && (
<Button
variant="ghost"
size="sm"
onClick={() => setEditingProject(true)}
className="h-8"
>
<Edit2 className="w-3.5 h-3.5 mr-1.5" />
Edit
</Button>
)}
</div>
{editingProject ? (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name-settings">Project Name</Label>
<Input
id="project-name-settings"
value={projectName}
onChange={(e) => setProjectNameLocal(e.target.value)}
placeholder="Enter project name"
/>
</div>
<div className="space-y-2">
<Label>Project Icon</Label>
<IconPicker
selectedIcon={projectIcon}
onSelectIcon={setProjectIconLocal}
/>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
Cancel
</Button>
<Button size="sm" onClick={handleSaveProjectDetails} disabled={!projectName.trim()}>
Save Changes
</Button>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name-settings">Project Name</Label>
<Input
id="project-name-settings"
value={projectName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter project name"
/>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 rounded-lg bg-accent/30 border border-border/50">
<div className="text-sm">
<div className="font-medium text-foreground">{currentProject.name}</div>
<div className="text-muted-foreground text-xs mt-0.5">{currentProject.path}</div>
<div className="space-y-2">
<Label>Project Icon</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, currentProject.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && (
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
)}
</div>
)}
</div>
</div>
)}

View File

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