mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
- Introduced a floating toggle button for mobile to show/hide the sidebar when collapsed. - Updated sidebar behavior to completely hide on mobile when the new mobileSidebarHidden state is true. - Added logic to conditionally render sidebar components based on screen size using the new useIsCompact hook. - Enhanced SidebarHeader to include close and expand buttons for mobile views. - Refactored CollapseToggleButton to hide in compact mode. - Implemented HeaderActionsPanel for mobile actions in various views, improving accessibility and usability on smaller screens. These changes improve the user experience on mobile devices by providing better navigation options and visibility controls.
1003 lines
41 KiB
TypeScript
1003 lines
41 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { createLogger } from '@automaker/utils/logger';
|
|
import { useNavigate } from '@tanstack/react-router';
|
|
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
|
import { useOSDetection } from '@/hooks/use-os-detection';
|
|
import { getElectronAPI, isElectron } from '@/lib/electron';
|
|
import { initializeProject } from '@/lib/project-init';
|
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
import { isMac } from '@/lib/utils';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
|
import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal';
|
|
import type { StarterTemplate } from '@/lib/templates';
|
|
import {
|
|
FolderOpen,
|
|
Plus,
|
|
Folder,
|
|
Star,
|
|
Clock,
|
|
Loader2,
|
|
ChevronDown,
|
|
MessageSquare,
|
|
MoreVertical,
|
|
Trash2,
|
|
Search,
|
|
X,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import * as LucideIcons from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
|
|
const logger = createLogger('DashboardView');
|
|
|
|
function getOSAbbreviation(os: string): string {
|
|
switch (os) {
|
|
case 'mac':
|
|
return 'M';
|
|
case 'windows':
|
|
return 'W';
|
|
case 'linux':
|
|
return 'L';
|
|
default:
|
|
return '?';
|
|
}
|
|
}
|
|
|
|
function getIconComponent(iconName?: string): LucideIcon {
|
|
if (iconName && iconName in LucideIcons) {
|
|
return (LucideIcons as unknown as Record<string, LucideIcon>)[iconName];
|
|
}
|
|
return Folder;
|
|
}
|
|
|
|
export function DashboardView() {
|
|
const navigate = useNavigate();
|
|
const { os } = useOSDetection();
|
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
|
const appMode = import.meta.env.VITE_APP_MODE || '?';
|
|
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
|
|
|
|
const {
|
|
projects,
|
|
trashedProjects,
|
|
currentProject,
|
|
upsertAndSetCurrentProject,
|
|
addProject,
|
|
setCurrentProject,
|
|
toggleProjectFavorite,
|
|
moveProjectToTrash,
|
|
theme: globalTheme,
|
|
} = useAppStore();
|
|
|
|
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
|
|
const [showWorkspacePicker, setShowWorkspacePicker] = useState(false);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [isOpening, setIsOpening] = useState(false);
|
|
const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
// Sort projects: favorites first, then by last opened
|
|
const sortedProjects = [...projects].sort((a, b) => {
|
|
// Favorites first
|
|
if (a.isFavorite && !b.isFavorite) return -1;
|
|
if (!a.isFavorite && b.isFavorite) return 1;
|
|
// Then by last opened
|
|
const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
|
|
const dateB = b.lastOpened ? new Date(b.lastOpened).getTime() : 0;
|
|
return dateB - dateA;
|
|
});
|
|
|
|
// Filter projects based on search query
|
|
const filteredProjects = sortedProjects.filter((project) => {
|
|
if (!searchQuery.trim()) return true;
|
|
const query = searchQuery.toLowerCase();
|
|
return project.name.toLowerCase().includes(query) || project.path.toLowerCase().includes(query);
|
|
});
|
|
|
|
const favoriteProjects = filteredProjects.filter((p) => p.isFavorite);
|
|
const recentProjects = filteredProjects.filter((p) => !p.isFavorite);
|
|
|
|
/**
|
|
* Initialize project and navigate to board
|
|
*/
|
|
const initializeAndOpenProject = useCallback(
|
|
async (path: string, name: string) => {
|
|
setIsOpening(true);
|
|
try {
|
|
const initResult = await initializeProject(path);
|
|
|
|
if (!initResult.success) {
|
|
toast.error('Failed to initialize project', {
|
|
description: initResult.error || 'Unknown error occurred',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const trashedProject = trashedProjects.find((p) => p.path === path);
|
|
const effectiveTheme =
|
|
(trashedProject?.theme as ThemeMode | undefined) ||
|
|
(currentProject?.theme as ThemeMode | undefined) ||
|
|
globalTheme;
|
|
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
|
|
|
toast.success('Project opened', {
|
|
description: `Opened ${name}`,
|
|
});
|
|
|
|
navigate({ to: '/board' });
|
|
} catch (error) {
|
|
logger.error('[Dashboard] Failed to open project:', error);
|
|
toast.error('Failed to open project', {
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
} finally {
|
|
setIsOpening(false);
|
|
}
|
|
},
|
|
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]
|
|
);
|
|
|
|
const handleOpenProject = useCallback(async () => {
|
|
try {
|
|
const httpClient = getHttpApiClient();
|
|
const configResult = await httpClient.workspace.getConfig();
|
|
|
|
if (configResult.success && configResult.configured) {
|
|
setShowWorkspacePicker(true);
|
|
} else {
|
|
const api = getElectronAPI();
|
|
const result = await api.openDirectory();
|
|
|
|
if (!result.canceled && result.filePaths[0]) {
|
|
const path = result.filePaths[0];
|
|
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
|
await initializeAndOpenProject(path, name);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('[Dashboard] Failed to check workspace config:', error);
|
|
const api = getElectronAPI();
|
|
const result = await api.openDirectory();
|
|
|
|
if (!result.canceled && result.filePaths[0]) {
|
|
const path = result.filePaths[0];
|
|
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
|
await initializeAndOpenProject(path, name);
|
|
}
|
|
}
|
|
}, [initializeAndOpenProject]);
|
|
|
|
const handleWorkspaceSelect = useCallback(
|
|
async (path: string, name: string) => {
|
|
setShowWorkspacePicker(false);
|
|
await initializeAndOpenProject(path, name);
|
|
},
|
|
[initializeAndOpenProject]
|
|
);
|
|
|
|
const handleProjectClick = useCallback(
|
|
async (project: { id: string; name: string; path: string }) => {
|
|
await initializeAndOpenProject(project.path, project.name);
|
|
},
|
|
[initializeAndOpenProject]
|
|
);
|
|
|
|
const handleToggleFavorite = useCallback(
|
|
(e: React.MouseEvent, projectId: string) => {
|
|
e.stopPropagation();
|
|
toggleProjectFavorite(projectId);
|
|
},
|
|
[toggleProjectFavorite]
|
|
);
|
|
|
|
const handleRemoveProject = useCallback(
|
|
(e: React.MouseEvent, project: { id: string; name: string }) => {
|
|
e.stopPropagation();
|
|
setProjectToRemove(project);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleConfirmRemove = useCallback(() => {
|
|
if (projectToRemove) {
|
|
moveProjectToTrash(projectToRemove.id);
|
|
toast.success('Project removed', {
|
|
description: `${projectToRemove.name} has been removed from your projects list`,
|
|
});
|
|
setProjectToRemove(null);
|
|
}
|
|
}, [projectToRemove, moveProjectToTrash]);
|
|
|
|
const handleNewProject = () => {
|
|
setShowNewProjectModal(true);
|
|
};
|
|
|
|
const handleInteractiveMode = () => {
|
|
navigate({ to: '/interview' });
|
|
};
|
|
|
|
const handleCreateBlankProject = async (projectName: string, parentDir: string) => {
|
|
setIsCreating(true);
|
|
try {
|
|
const api = getElectronAPI();
|
|
const projectPath = `${parentDir}/${projectName}`;
|
|
|
|
const parentExists = await api.exists(parentDir);
|
|
if (!parentExists) {
|
|
toast.error('Parent directory does not exist', {
|
|
description: `Cannot create project in non-existent directory: ${parentDir}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const parentStat = await api.stat(parentDir);
|
|
if (parentStat && !parentStat.stats?.isDirectory) {
|
|
toast.error('Parent path is not a directory', {
|
|
description: `${parentDir} is not a directory`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const mkdirResult = await api.mkdir(projectPath);
|
|
if (!mkdirResult.success) {
|
|
toast.error('Failed to create project directory', {
|
|
description: mkdirResult.error || 'Unknown error occurred',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const initResult = await initializeProject(projectPath);
|
|
if (!initResult.success) {
|
|
toast.error('Failed to initialize project', {
|
|
description: initResult.error || 'Unknown error occurred',
|
|
});
|
|
return;
|
|
}
|
|
|
|
await api.writeFile(
|
|
`${projectPath}/.automaker/app_spec.txt`,
|
|
`<project_specification>
|
|
<project_name>${projectName}</project_name>
|
|
|
|
<overview>
|
|
Describe your project here. This file will be analyzed by an AI agent
|
|
to understand your project structure and tech stack.
|
|
</overview>
|
|
|
|
<technology_stack>
|
|
<!-- The AI agent will fill this in after analyzing your project -->
|
|
</technology_stack>
|
|
|
|
<core_capabilities>
|
|
<!-- List core features and capabilities -->
|
|
</core_capabilities>
|
|
|
|
<implemented_features>
|
|
<!-- The AI agent will populate this based on code analysis -->
|
|
</implemented_features>
|
|
</project_specification>`
|
|
);
|
|
|
|
const project = {
|
|
id: `project-${Date.now()}`,
|
|
name: projectName,
|
|
path: projectPath,
|
|
lastOpened: new Date().toISOString(),
|
|
};
|
|
|
|
addProject(project);
|
|
setCurrentProject(project);
|
|
setShowNewProjectModal(false);
|
|
|
|
toast.success('Project created', {
|
|
description: `Created ${projectName}`,
|
|
});
|
|
|
|
navigate({ to: '/board' });
|
|
} catch (error) {
|
|
logger.error('Failed to create project:', error);
|
|
toast.error('Failed to create project', {
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateFromTemplate = async (
|
|
template: StarterTemplate,
|
|
projectName: string,
|
|
parentDir: string
|
|
) => {
|
|
setIsCreating(true);
|
|
try {
|
|
const httpClient = getHttpApiClient();
|
|
const api = getElectronAPI();
|
|
|
|
const cloneResult = await httpClient.templates.clone(
|
|
template.repoUrl,
|
|
projectName,
|
|
parentDir
|
|
);
|
|
if (!cloneResult.success || !cloneResult.projectPath) {
|
|
toast.error('Failed to clone template', {
|
|
description: cloneResult.error || 'Unknown error occurred',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const projectPath = cloneResult.projectPath;
|
|
const initResult = await initializeProject(projectPath);
|
|
if (!initResult.success) {
|
|
toast.error('Failed to initialize project', {
|
|
description: initResult.error || 'Unknown error occurred',
|
|
});
|
|
return;
|
|
}
|
|
|
|
await api.writeFile(
|
|
`${projectPath}/.automaker/app_spec.txt`,
|
|
`<project_specification>
|
|
<project_name>${projectName}</project_name>
|
|
|
|
<overview>
|
|
This project was created from the "${template.name}" starter template.
|
|
${template.description}
|
|
</overview>
|
|
|
|
<technology_stack>
|
|
${template.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')}
|
|
</technology_stack>
|
|
|
|
<core_capabilities>
|
|
${template.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')}
|
|
</core_capabilities>
|
|
|
|
<implemented_features>
|
|
<!-- The AI agent will populate this based on code analysis -->
|
|
</implemented_features>
|
|
</project_specification>`
|
|
);
|
|
|
|
const project = {
|
|
id: `project-${Date.now()}`,
|
|
name: projectName,
|
|
path: projectPath,
|
|
lastOpened: new Date().toISOString(),
|
|
};
|
|
|
|
addProject(project);
|
|
setCurrentProject(project);
|
|
setShowNewProjectModal(false);
|
|
|
|
toast.success('Project created from template', {
|
|
description: `Created ${projectName} from ${template.name}`,
|
|
});
|
|
|
|
navigate({ to: '/board' });
|
|
} catch (error) {
|
|
logger.error('Failed to create project from template:', error);
|
|
toast.error('Failed to create project', {
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateFromCustomUrl = async (
|
|
repoUrl: string,
|
|
projectName: string,
|
|
parentDir: string
|
|
) => {
|
|
setIsCreating(true);
|
|
try {
|
|
const httpClient = getHttpApiClient();
|
|
const api = getElectronAPI();
|
|
|
|
const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir);
|
|
if (!cloneResult.success || !cloneResult.projectPath) {
|
|
toast.error('Failed to clone repository', {
|
|
description: cloneResult.error || 'Unknown error occurred',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const projectPath = cloneResult.projectPath;
|
|
const initResult = await initializeProject(projectPath);
|
|
if (!initResult.success) {
|
|
toast.error('Failed to initialize project', {
|
|
description: initResult.error || 'Unknown error occurred',
|
|
});
|
|
return;
|
|
}
|
|
|
|
await api.writeFile(
|
|
`${projectPath}/.automaker/app_spec.txt`,
|
|
`<project_specification>
|
|
<project_name>${projectName}</project_name>
|
|
|
|
<overview>
|
|
This project was cloned from ${repoUrl}.
|
|
The AI agent will analyze the project structure.
|
|
</overview>
|
|
|
|
<technology_stack>
|
|
<!-- The AI agent will fill this in after analyzing your project -->
|
|
</technology_stack>
|
|
|
|
<core_capabilities>
|
|
<!-- List core features and capabilities -->
|
|
</core_capabilities>
|
|
|
|
<implemented_features>
|
|
<!-- The AI agent will populate this based on code analysis -->
|
|
</implemented_features>
|
|
</project_specification>`
|
|
);
|
|
|
|
const project = {
|
|
id: `project-${Date.now()}`,
|
|
name: projectName,
|
|
path: projectPath,
|
|
lastOpened: new Date().toISOString(),
|
|
};
|
|
|
|
addProject(project);
|
|
setCurrentProject(project);
|
|
setShowNewProjectModal(false);
|
|
|
|
toast.success('Project created from repository', {
|
|
description: `Created ${projectName}`,
|
|
});
|
|
|
|
navigate({ to: '/board' });
|
|
} catch (error) {
|
|
logger.error('Failed to create project from custom URL:', error);
|
|
toast.error('Failed to create project', {
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
const hasProjects = projects.length > 0;
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="dashboard-view">
|
|
{/* Header with logo */}
|
|
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
|
{/* Electron titlebar drag region */}
|
|
{isElectron() && (
|
|
<div
|
|
className={`absolute top-0 left-0 right-0 h-6 titlebar-drag-region z-40 pointer-events-none ${isMac ? 'pl-20' : ''}`}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<div className="px-4 sm:px-8 py-4 flex items-center justify-between">
|
|
<div
|
|
className="flex items-center gap-2 sm:gap-3 cursor-pointer group titlebar-no-drag"
|
|
onClick={() => navigate({ to: '/dashboard' })}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 256 256"
|
|
role="img"
|
|
aria-label="Automaker Logo"
|
|
className="size-8 sm:size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
|
>
|
|
<defs>
|
|
<linearGradient
|
|
id="bg-dashboard"
|
|
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>
|
|
<filter id="iconShadow-dashboard" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feDropShadow
|
|
dx="0"
|
|
dy="4"
|
|
stdDeviation="4"
|
|
floodColor="#000000"
|
|
floodOpacity="0.25"
|
|
/>
|
|
</filter>
|
|
</defs>
|
|
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-dashboard)" />
|
|
<g
|
|
fill="none"
|
|
stroke="#FFFFFF"
|
|
strokeWidth="20"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
filter="url(#iconShadow-dashboard)"
|
|
>
|
|
<path d="M92 92 L52 128 L92 164" />
|
|
<path d="M144 72 L116 184" />
|
|
<path d="M164 92 L204 128 L164 164" />
|
|
</g>
|
|
</svg>
|
|
<div className="flex flex-col">
|
|
<span className="font-bold text-foreground text-xl sm:text-2xl tracking-tight leading-none">
|
|
automaker<span className="text-brand-500">.</span>
|
|
</span>
|
|
<span className="text-xs text-muted-foreground leading-none font-medium mt-1">
|
|
v{appVersion} {versionSuffix}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile action buttons in header */}
|
|
{hasProjects && (
|
|
<div className="flex sm:hidden gap-2 titlebar-no-drag">
|
|
<Button variant="outline" size="icon" onClick={handleOpenProject}>
|
|
<FolderOpen className="w-4 h-4" />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
size="icon"
|
|
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
<DropdownMenuItem onClick={handleNewProject}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Quick Setup
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleInteractiveMode}>
|
|
<MessageSquare className="w-4 h-4 mr-2" />
|
|
Interactive Mode
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main content */}
|
|
<div className="flex-1 overflow-y-auto p-4 sm:p-8">
|
|
<div className="max-w-6xl mx-auto">
|
|
{/* No projects - show getting started */}
|
|
{!hasProjects && (
|
|
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
<div className="text-center mb-8 sm:mb-12">
|
|
<h2 className="text-2xl sm:text-3xl font-bold text-foreground mb-3">
|
|
Welcome to Automaker
|
|
</h2>
|
|
<p className="text-base sm:text-lg text-muted-foreground max-w-xl mx-auto px-2">
|
|
Your autonomous AI development studio. Get started by creating a new project or
|
|
opening an existing one.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 max-w-3xl mx-auto">
|
|
{/* New Project Card */}
|
|
<div
|
|
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-brand-500/30 hover:shadow-xl hover:shadow-brand-500/5 transition-all duration-300 hover:-translate-y-1"
|
|
data-testid="new-project-card"
|
|
>
|
|
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
<div className="relative p-4 sm:p-6 h-full flex flex-col">
|
|
<div className="flex items-start gap-3 sm:gap-4 flex-1">
|
|
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
|
|
<Plus className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
|
New Project
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
Create a new project from scratch with AI-powered development
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
className="w-full mt-4 sm:mt-5 bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
|
|
data-testid="create-new-project"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Create Project
|
|
<ChevronDown className="w-4 h-4 ml-2" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
<DropdownMenuItem
|
|
onClick={handleNewProject}
|
|
data-testid="quick-setup-option"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Quick Setup
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={handleInteractiveMode}
|
|
data-testid="interactive-mode-option"
|
|
>
|
|
<MessageSquare className="w-4 h-4 mr-2" />
|
|
Interactive Mode
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Open Project Card */}
|
|
<div
|
|
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-blue-500/30 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 cursor-pointer hover:-translate-y-1"
|
|
onClick={handleOpenProject}
|
|
data-testid="open-project-card"
|
|
>
|
|
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
<div className="relative p-4 sm:p-6 h-full flex flex-col">
|
|
<div className="flex items-start gap-3 sm:gap-4 flex-1">
|
|
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-muted border border-border flex items-center justify-center group-hover:bg-blue-500/10 group-hover:border-blue-500/30 group-hover:scale-105 transition-all duration-300 shrink-0">
|
|
<FolderOpen className="w-5 h-5 sm:w-6 sm:h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
|
Open Project
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
Open an existing project folder to continue working
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
className="w-full mt-4 sm:mt-5 bg-secondary/80 hover:bg-secondary text-foreground border border-border hover:border-blue-500/30 transition-all"
|
|
data-testid="open-existing-project"
|
|
>
|
|
<FolderOpen className="w-4 h-4 mr-2" />
|
|
Browse Folder
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Has projects - show project list */}
|
|
{hasProjects && (
|
|
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
{/* Search and actions header */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<h2 className="text-xl sm:text-2xl font-bold text-foreground">Your Projects</h2>
|
|
<div className="flex items-center gap-2">
|
|
{/* Search input */}
|
|
<div className="relative flex-1 sm:flex-none">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search projects..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9 pr-8 w-full sm:w-64"
|
|
data-testid="project-search-input"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted transition-colors"
|
|
title="Clear search"
|
|
>
|
|
<X className="w-4 h-4 text-muted-foreground" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* Desktop only buttons */}
|
|
<Button variant="outline" onClick={handleOpenProject} className="hidden sm:flex">
|
|
<FolderOpen className="w-4 h-4 mr-2" />
|
|
Open Folder
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button className="hidden sm:flex bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
New Project
|
|
<ChevronDown className="w-4 h-4 ml-2" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
<DropdownMenuItem onClick={handleNewProject}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Quick Setup
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleInteractiveMode}>
|
|
<MessageSquare className="w-4 h-4 mr-2" />
|
|
Interactive Mode
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Favorites section */}
|
|
{favoriteProjects.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
|
|
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-yellow-500/10 flex items-center justify-center">
|
|
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-yellow-500 fill-yellow-500" />
|
|
</div>
|
|
<h3 className="text-base sm:text-lg font-semibold text-foreground">
|
|
Favorites
|
|
</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
|
{favoriteProjects.map((project) => (
|
|
<div
|
|
key={project.id}
|
|
className="group relative rounded-xl border border-yellow-500/30 bg-card/60 backdrop-blur-sm hover:bg-card hover:border-yellow-500/50 hover:shadow-lg hover:shadow-yellow-500/5 transition-all duration-300 cursor-pointer hover:-translate-y-0.5"
|
|
onClick={() => handleProjectClick(project)}
|
|
data-testid={`project-card-${project.id}`}
|
|
>
|
|
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" />
|
|
<div className="relative p-3 sm:p-4">
|
|
<div className="flex items-start gap-2.5 sm:gap-3">
|
|
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0 overflow-hidden">
|
|
{project.customIconPath ? (
|
|
<img
|
|
src={getAuthenticatedImageUrl(
|
|
project.customIconPath,
|
|
project.path
|
|
)}
|
|
alt={project.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
(() => {
|
|
const IconComponent = getIconComponent(project.icon);
|
|
return (
|
|
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
|
|
);
|
|
})()
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
|
|
{project.name}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground/70 truncate mt-0.5 sm:mt-1">
|
|
{project.path}
|
|
</p>
|
|
{project.lastOpened && (
|
|
<p className="text-xs text-muted-foreground mt-1 sm:mt-1.5">
|
|
{new Date(project.lastOpened).toLocaleDateString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-0.5 sm:gap-1">
|
|
<button
|
|
onClick={(e) => handleToggleFavorite(e, project.id)}
|
|
className="p-1 sm:p-1.5 rounded-lg hover:bg-yellow-500/20 transition-colors"
|
|
title="Remove from favorites"
|
|
>
|
|
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-yellow-500 fill-yellow-500" />
|
|
</button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
|
title="More options"
|
|
>
|
|
<MoreVertical className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={(e) => handleRemoveProject(e, project)}
|
|
className="text-destructive focus:text-destructive"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Remove from Automaker
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent projects section */}
|
|
{recentProjects.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
|
|
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-muted/50 flex items-center justify-center">
|
|
<Clock className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-base sm:text-lg font-semibold text-foreground">
|
|
Recent Projects
|
|
</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
|
{recentProjects.map((project) => (
|
|
<div
|
|
key={project.id}
|
|
className="group relative rounded-xl border border-border bg-card/60 backdrop-blur-sm hover:bg-card hover:border-brand-500/40 hover:shadow-lg hover:shadow-brand-500/5 transition-all duration-300 cursor-pointer hover:-translate-y-0.5"
|
|
onClick={() => handleProjectClick(project)}
|
|
data-testid={`project-card-${project.id}`}
|
|
>
|
|
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
|
|
<div className="relative p-3 sm:p-4">
|
|
<div className="flex items-start gap-2.5 sm:gap-3">
|
|
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0 overflow-hidden">
|
|
{project.customIconPath ? (
|
|
<img
|
|
src={getAuthenticatedImageUrl(
|
|
project.customIconPath,
|
|
project.path
|
|
)}
|
|
alt={project.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
(() => {
|
|
const IconComponent = getIconComponent(project.icon);
|
|
return (
|
|
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
|
|
);
|
|
})()
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
|
|
{project.name}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground/70 truncate mt-0.5 sm:mt-1">
|
|
{project.path}
|
|
</p>
|
|
{project.lastOpened && (
|
|
<p className="text-xs text-muted-foreground mt-1 sm:mt-1.5">
|
|
{new Date(project.lastOpened).toLocaleDateString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-0.5 sm:gap-1">
|
|
<button
|
|
onClick={(e) => handleToggleFavorite(e, project.id)}
|
|
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors"
|
|
title="Add to favorites"
|
|
>
|
|
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground/50 hover:text-yellow-500 transition-colors" />
|
|
</button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
|
title="More options"
|
|
>
|
|
<MoreVertical className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={(e) => handleRemoveProject(e, project)}
|
|
className="text-destructive focus:text-destructive"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Remove from Automaker
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* No search results */}
|
|
{searchQuery && favoriteProjects.length === 0 && recentProjects.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
|
<Search className="w-8 h-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-foreground mb-2">No projects found</h3>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
No projects match "{searchQuery}"
|
|
</p>
|
|
<Button variant="outline" size="sm" onClick={() => setSearchQuery('')}>
|
|
Clear search
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modals */}
|
|
<NewProjectModal
|
|
open={showNewProjectModal}
|
|
onOpenChange={setShowNewProjectModal}
|
|
onCreateBlankProject={handleCreateBlankProject}
|
|
onCreateFromTemplate={handleCreateFromTemplate}
|
|
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
|
isCreating={isCreating}
|
|
/>
|
|
|
|
<WorkspacePickerModal
|
|
open={showWorkspacePicker}
|
|
onOpenChange={setShowWorkspacePicker}
|
|
onSelect={handleWorkspaceSelect}
|
|
/>
|
|
|
|
{/* Remove project confirmation dialog */}
|
|
<Dialog open={!!projectToRemove} onOpenChange={(open) => !open && setProjectToRemove(null)}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Remove Project</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to remove <strong>{projectToRemove?.name}</strong> from
|
|
Automaker?
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
This will only remove the project from your Automaker projects list. The project files
|
|
on your computer will not be deleted.
|
|
</p>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setProjectToRemove(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleConfirmRemove}>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Remove Project
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Loading overlay */}
|
|
{isOpening && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
|
|
data-testid="project-opening-overlay"
|
|
>
|
|
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-card border border-border shadow-2xl">
|
|
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
|
|
<p className="text-foreground font-medium">Opening project...</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|