Squashed commit of the following:

commit 38f68d5ed489f416ea91630aea3496ab15365e66
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 16:16:28 2025 -0400

    Fix click and drag

commit eb5c41cf31fd8032fe88fd47fe5f3e7f517f6d30
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 15:57:13 2025 -0400

    Update tasks

commit 979d4b15373df3193eb1bd41c18ece1dbe044eba
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 15:19:20 2025 -0400

    Status

commit fa26f1fa8fefe1d84b5b2153327c7e8c0132952a
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 14:54:48 2025 -0400

    Cleanup project card

commit 14d7a1d7d7574dd8745167b280c04603ba22b189
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 14:49:19 2025 -0400

    Improve existing vs new repo

commit 277e1f05ef68e5c67d73b246557a6df2ab23d32c
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 13:01:21 2025 -0400

    Make repo path unique

commit f80ef55f2ba16836276a81844fc33639872bcc53
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 12:52:20 2025 -0400

    Fix styles

commit 077869458fcab199a10ef0fe2fe39f9f4216ce5b
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 12:41:48 2025 -0400

    First select repo

commit 1b0d9c0280e4cb75294348bb53b2a534458a2e37
Author: Louis Knight-Webb <louis@bloop.ai>
Date:   Mon Jun 16 11:45:19 2025 -0400

    Init
This commit is contained in:
Louis Knight-Webb
2025-06-16 16:16:42 -04:00
parent eca26240fe
commit 22edb7a1db
26 changed files with 2041 additions and 409 deletions

View File

@@ -1,88 +1,267 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Project, CreateProject, UpdateProject } from 'shared/types'
import { AlertCircle } from 'lucide-react'
import { makeAuthenticatedRequest } from '@/lib/auth'
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FolderPicker } from "@/components/ui/folder-picker";
import { Project, CreateProject, UpdateProject } from "shared/types";
import { AlertCircle, Folder } from "lucide-react";
import { makeAuthenticatedRequest } from "@/lib/auth";
interface ProjectFormProps {
open: boolean
onClose: () => void
onSuccess: () => void
project?: Project | null
open: boolean;
onClose: () => void;
onSuccess: () => void;
project?: Project | null;
}
export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormProps) {
const [name, setName] = useState(project?.name || '')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
export function ProjectForm({
open,
onClose,
onSuccess,
project,
}: ProjectFormProps) {
const [name, setName] = useState(project?.name || "");
const [gitRepoPath, setGitRepoPath] = useState(project?.git_repo_path || "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [showFolderPicker, setShowFolderPicker] = useState(false);
const [repoMode, setRepoMode] = useState<"existing" | "new">("existing");
const [parentPath, setParentPath] = useState("");
const [folderName, setFolderName] = useState("");
const isEditing = !!project
const isEditing = !!project;
// Auto-populate project name from directory name
const handleGitRepoPathChange = (path: string) => {
setGitRepoPath(path);
// Only auto-populate name for new projects
if (!isEditing && path) {
// Extract the last part of the path (directory name)
const dirName = path.split("/").filter(Boolean).pop() || "";
if (dirName) {
// Clean up the directory name for a better project name
const cleanName = dirName
.replace(/[-_]/g, " ") // Replace hyphens and underscores with spaces
.replace(/\b\w/g, (l) => l.toUpperCase()); // Capitalize first letter of each word
setName(cleanName);
}
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
e.preventDefault();
setError("");
setLoading(true);
try {
let finalGitRepoPath = gitRepoPath;
// For new repo mode, construct the full path
if (!isEditing && repoMode === "new") {
finalGitRepoPath = `${parentPath}/${folderName}`.replace(/\/+/g, "/");
}
if (isEditing) {
const updateData: UpdateProject = { name }
const response = await makeAuthenticatedRequest(`/api/projects/${project.id}`, {
method: 'PUT',
body: JSON.stringify(updateData),
})
const updateData: UpdateProject = {
name,
git_repo_path: finalGitRepoPath,
};
const response = await makeAuthenticatedRequest(
`/api/projects/${project.id}`,
{
method: "PUT",
body: JSON.stringify(updateData),
}
);
if (!response.ok) {
throw new Error('Failed to update project')
throw new Error("Failed to update project");
}
const data = await response.json();
if (!data.success) {
throw new Error(data.message || "Failed to update project");
}
} else {
const createData: CreateProject = {
name
}
const response = await makeAuthenticatedRequest('/api/projects', {
method: 'POST',
const createData: CreateProject = {
name,
git_repo_path: finalGitRepoPath,
use_existing_repo: repoMode === "existing",
};
const response = await makeAuthenticatedRequest("/api/projects", {
method: "POST",
body: JSON.stringify(createData),
})
});
if (!response.ok) {
throw new Error('Failed to create project')
throw new Error("Failed to create project");
}
const data = await response.json();
if (!data.success) {
throw new Error(data.message || "Failed to create project");
}
}
onSuccess()
setName('')
onSuccess();
setName("");
setGitRepoPath("");
setParentPath("");
setFolderName("");
} catch (error) {
setError(error instanceof Error ? error.message : 'An error occurred')
setError(error instanceof Error ? error.message : "An error occurred");
} finally {
setLoading(false)
setLoading(false);
}
}
};
const handleClose = () => {
setName(project?.name || '')
setError('')
onClose()
}
setName(project?.name || "");
setGitRepoPath(project?.git_repo_path || "");
setError("");
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{isEditing ? 'Edit Project' : 'Create New Project'}
{isEditing ? "Edit Project" : "Create New Project"}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Make changes to your project here. Click save when you\'re done.'
: 'Add a new project to your workspace. You can always edit it later.'
}
{isEditing
? "Make changes to your project here. Click save when you're done."
: "Choose whether to use an existing git repository or create a new one."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{!isEditing && (
<div className="space-y-3">
<Label>Repository Type</Label>
<div className="flex space-x-4">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
name="repoMode"
value="existing"
checked={repoMode === "existing"}
onChange={(e) =>
setRepoMode(e.target.value as "existing" | "new")
}
className="text-primary"
/>
<span className="text-sm">Use existing repository</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
name="repoMode"
value="new"
checked={repoMode === "new"}
onChange={(e) =>
setRepoMode(e.target.value as "existing" | "new")
}
className="text-primary"
/>
<span className="text-sm">Create new repository</span>
</label>
</div>
</div>
)}
{repoMode === "existing" || isEditing ? (
<div className="space-y-2">
<Label htmlFor="git-repo-path">Git Repository Path</Label>
<div className="flex space-x-2">
<Input
id="git-repo-path"
type="text"
value={gitRepoPath}
onChange={(e) => handleGitRepoPathChange(e.target.value)}
placeholder="/path/to/your/existing/repo"
required
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => setShowFolderPicker(true)}
>
<Folder className="h-4 w-4" />
</Button>
</div>
{!isEditing && (
<p className="text-sm text-muted-foreground">
Select a folder that already contains a git repository
</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="parent-path">Parent Directory</Label>
<div className="flex space-x-2">
<Input
id="parent-path"
type="text"
value={parentPath}
onChange={(e) => setParentPath(e.target.value)}
placeholder="/path/to/parent/directory"
required
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => setShowFolderPicker(true)}
>
<Folder className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Choose where to create the new repository
</p>
</div>
<div className="space-y-2">
<Label htmlFor="folder-name">Repository Folder Name</Label>
<Input
id="folder-name"
type="text"
value={folderName}
onChange={(e) => {
setFolderName(e.target.value);
if (e.target.value) {
setName(
e.target.value
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())
);
}
}}
placeholder="my-awesome-project"
required
className="flex-1"
/>
<p className="text-sm text-muted-foreground">
The project name will be auto-populated from this folder name
</p>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Project Name</Label>
<Input
@@ -98,9 +277,7 @@ export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormPr
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -113,12 +290,49 @@ export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormPr
>
Cancel
</Button>
<Button type="submit" disabled={loading || !name.trim()}>
{loading ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Project'}
<Button
type="submit"
disabled={
loading ||
!name.trim() ||
(repoMode === "existing" || isEditing
? !gitRepoPath.trim()
: !parentPath.trim() || !folderName.trim())
}
>
{loading
? "Saving..."
: isEditing
? "Save Changes"
: "Create Project"}
</Button>
</DialogFooter>
</form>
</DialogContent>
<FolderPicker
open={showFolderPicker}
onClose={() => setShowFolderPicker(false)}
onSelect={(path) => {
if (repoMode === "existing" || isEditing) {
handleGitRepoPathChange(path);
} else {
setParentPath(path);
}
setShowFolderPicker(false);
}}
value={repoMode === "existing" || isEditing ? gitRepoPath : parentPath}
title={
repoMode === "existing" || isEditing
? "Select Git Repository"
: "Select Parent Directory"
}
description={
repoMode === "existing" || isEditing
? "Choose an existing git repository"
: "Choose where to create the new repository"
}
/>
</Dialog>
)
);
}

View File

@@ -7,7 +7,13 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { Project, ApiResponse } from 'shared/types'
import { ProjectForm } from './project-form'
import { makeAuthenticatedRequest } from '@/lib/auth'
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, MoreHorizontal, ExternalLink } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function ProjectList() {
const navigate = useNavigate()
@@ -118,54 +124,64 @@ export function ProjectList() {
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Card key={project.id} className="hover:shadow-md transition-shadow">
<Card
key={project.id}
className="hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate(`/projects/${project.id}/tasks`)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle
className="text-lg cursor-pointer hover:text-primary"
onClick={() => navigate(`/projects/${project.id}`)}
>
<CardTitle className="text-lg">
{project.name}
</CardTitle>
<Badge variant="secondary" className="ml-2">
Active
</Badge>
<div className="flex items-center gap-2">
<Badge variant="secondary">
Active
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => {
e.stopPropagation()
navigate(`/projects/${project.id}`)
}}>
<ExternalLink className="mr-2 h-4 w-4" />
View Project
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation()
handleEdit(project)
}}>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handleDelete(project.id, project.name)
}}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CardDescription className="flex items-center">
<Calendar className="mr-1 h-3 w-3" />
Created {new Date(project.created_at).toLocaleDateString()}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => navigate(`/projects/${project.id}/tasks`)}
className="h-8"
>
<CheckSquare className="mr-1 h-3 w-3" />
Tasks
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(project)}
className="h-8"
>
<Edit className="mr-1 h-3 w-3" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(project.id, project.name)}
className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="mr-1 h-3 w-3" />
Delete
</Button>
</div>
</CardContent>
</Card>
))}
</div>

View File

@@ -0,0 +1,92 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { KanbanCard } from '@/components/ui/shadcn-io/kanban'
import { MoreHorizontal, Trash2, Edit } from 'lucide-react'
import type { TaskStatus } from 'shared/types'
interface Task {
id: string
project_id: string
title: string
description: string | null
status: TaskStatus
created_at: string
updated_at: string
}
interface TaskCardProps {
task: Task
index: number
status: string
onEdit: (task: Task) => void
onDelete: (taskId: string) => void
onViewDetails: (task: Task) => void
}
export function TaskCard({ task, index, status, onEdit, onDelete, onViewDetails }: TaskCardProps) {
return (
<KanbanCard
key={task.id}
id={task.id}
name={task.title}
index={index}
parent={status}
onClick={() => onViewDetails(task)}
>
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="flex-1 pr-2">
<h4 className="font-medium text-sm">
{task.title}
</h4>
</div>
<div className="flex items-center space-x-1">
{/* Actions Menu */}
<div
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-gray-100"
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(task)}>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(task.id)}
className="text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{task.description && (
<div>
<p className="text-xs text-muted-foreground">
{task.description}
</p>
</div>
)}
</div>
</KanbanCard>
)
}

View File

@@ -0,0 +1,71 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
interface TaskCreateDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onCreateTask: (title: string, description: string) => Promise<void>
}
export function TaskCreateDialog({ isOpen, onOpenChange, onCreateTask }: TaskCreateDialogProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const handleCreate = async () => {
if (!title.trim()) return
await onCreateTask(title, description)
setTitle('')
setDescription('')
onOpenChange(false)
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter task title"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter task description (optional)"
rows={3}
/>
</div>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleCreate}>Create Task</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,235 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { makeAuthenticatedRequest } from '@/lib/auth'
import type { TaskStatus, TaskAttempt, TaskAttemptActivity } from 'shared/types'
interface Task {
id: string
project_id: string
title: string
description: string | null
status: TaskStatus
created_at: string
updated_at: string
}
interface ApiResponse<T> {
success: boolean
data: T | null
message: string | null
}
interface TaskDetailsDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
task: Task | null
projectId: string
onError: (error: string) => void
}
const statusLabels: Record<TaskStatus, string> = {
todo: 'To Do',
inprogress: 'In Progress',
inreview: 'In Review',
done: 'Done',
cancelled: 'Cancelled'
}
export function TaskDetailsDialog({ isOpen, onOpenChange, task, projectId, onError }: TaskDetailsDialogProps) {
const [taskAttempts, setTaskAttempts] = useState<TaskAttempt[]>([])
const [taskAttemptsLoading, setTaskAttemptsLoading] = useState(false)
const [selectedAttempt, setSelectedAttempt] = useState<TaskAttempt | null>(null)
const [attemptActivities, setAttemptActivities] = useState<TaskAttemptActivity[]>([])
const [activitiesLoading, setActivitiesLoading] = useState(false)
useEffect(() => {
if (isOpen && task) {
fetchTaskAttempts(task.id)
}
}, [isOpen, task])
const fetchTaskAttempts = async (taskId: string) => {
try {
setTaskAttemptsLoading(true)
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${taskId}/attempts`)
if (response.ok) {
const result: ApiResponse<TaskAttempt[]> = await response.json()
if (result.success && result.data) {
setTaskAttempts(result.data)
}
} else {
onError('Failed to load task attempts')
}
} catch (err) {
onError('Failed to load task attempts')
} finally {
setTaskAttemptsLoading(false)
}
}
const fetchAttemptActivities = async (attemptId: string) => {
if (!task) return
try {
setActivitiesLoading(true)
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`)
if (response.ok) {
const result: ApiResponse<TaskAttemptActivity[]> = await response.json()
if (result.success && result.data) {
setAttemptActivities(result.data)
}
} else {
onError('Failed to load attempt activities')
}
} catch (err) {
onError('Failed to load attempt activities')
} finally {
setActivitiesLoading(false)
}
}
const handleAttemptClick = (attempt: TaskAttempt) => {
setSelectedAttempt(attempt)
fetchAttemptActivities(attempt.id)
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Task Details: {task?.title}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Task Info */}
<div className="space-y-2">
<h3 className="text-lg font-semibold">Task Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Title</Label>
<p className="text-sm text-muted-foreground">{task?.title}</p>
</div>
<div>
<Label>Status</Label>
<p className="text-sm text-muted-foreground">
{task ? statusLabels[task.status] : ''}
</p>
</div>
</div>
{task?.description && (
<div>
<Label>Description</Label>
<p className="text-sm text-muted-foreground">{task.description}</p>
</div>
)}
</div>
{/* Task Attempts */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Task Attempts</h3>
{taskAttemptsLoading ? (
<div className="text-center py-4">Loading attempts...</div>
) : taskAttempts.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No attempts found for this task
</div>
) : (
<div className="space-y-2">
{taskAttempts.map((attempt) => (
<Card
key={attempt.id}
className={`cursor-pointer transition-colors ${
selectedAttempt?.id === attempt.id ? 'bg-blue-50 border-blue-200' : 'hover:bg-gray-50'
}`}
onClick={() => handleAttemptClick(attempt)}
>
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex justify-between items-start">
<div>
<p className="font-medium">Worktree: {attempt.worktree_path}</p>
<p className="text-sm text-muted-foreground">
Created: {new Date(attempt.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<Label className="text-xs">Base Commit</Label>
<p className="text-muted-foreground">
{attempt.base_commit || 'None'}
</p>
</div>
<div>
<Label className="text-xs">Merge Commit</Label>
<p className="text-muted-foreground">
{attempt.merge_commit || 'None'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Activity History */}
{selectedAttempt && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Activity History for Attempt: {selectedAttempt.worktree_path}
</h3>
{activitiesLoading ? (
<div className="text-center py-4">Loading activities...</div>
) : attemptActivities.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No activities found for this attempt
</div>
) : (
<div className="space-y-2">
{attemptActivities.map((activity) => (
<Card key={activity.id}>
<CardContent className="p-4">
<div className="flex justify-between items-start">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
activity.status === 'init' ? 'bg-gray-100 text-gray-800' :
activity.status === 'inprogress' ? 'bg-blue-100 text-blue-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{activity.status === 'init' ? 'Init' :
activity.status === 'inprogress' ? 'In Progress' :
'Paused'}
</span>
</div>
{activity.note && (
<p className="text-sm text-muted-foreground">{activity.note}</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{new Date(activity.created_at).toLocaleString()}
</p>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,115 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import type { TaskStatus } from 'shared/types'
interface Task {
id: string
project_id: string
title: string
description: string | null
status: TaskStatus
created_at: string
updated_at: string
}
interface TaskEditDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
task: Task | null
onUpdateTask: (title: string, description: string, status: TaskStatus) => Promise<void>
}
export function TaskEditDialog({ isOpen, onOpenChange, task, onUpdateTask }: TaskEditDialogProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [status, setStatus] = useState<TaskStatus>('todo')
useEffect(() => {
if (task) {
setTitle(task.title)
setDescription(task.description || '')
setStatus(task.status)
}
}, [task])
const handleUpdate = async () => {
if (!title.trim()) return
await onUpdateTask(title, description, status)
onOpenChange(false)
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Task</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-title">Title</Label>
<Input
id="edit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter task title"
/>
</div>
<div>
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter task description (optional)"
rows={3}
/>
</div>
<div>
<Label htmlFor="edit-status">Status</Label>
<Select
value={status}
onValueChange={(value) => setStatus(value as TaskStatus)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">To Do</SelectItem>
<SelectItem value="inprogress">In Progress</SelectItem>
<SelectItem value="inreview">In Review</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleUpdate}>Update Task</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,95 @@
import {
KanbanProvider,
KanbanBoard,
KanbanHeader,
KanbanCards,
type DragEndEvent
} from '@/components/ui/shadcn-io/kanban'
import { TaskCard } from './TaskCard'
import type { TaskStatus } from 'shared/types'
interface Task {
id: string
project_id: string
title: string
description: string | null
status: TaskStatus
created_at: string
updated_at: string
}
interface TaskKanbanBoardProps {
tasks: Task[]
onDragEnd: (event: DragEndEvent) => void
onEditTask: (task: Task) => void
onDeleteTask: (taskId: string) => void
onViewTaskDetails: (task: Task) => void
}
const allTaskStatuses: TaskStatus[] = ['todo', 'inprogress', 'inreview', 'done', 'cancelled']
const statusLabels: Record<TaskStatus, string> = {
todo: 'To Do',
inprogress: 'In Progress',
inreview: 'In Review',
done: 'Done',
cancelled: 'Cancelled'
}
const statusBoardColors: Record<TaskStatus, string> = {
todo: '#64748b',
inprogress: '#3b82f6',
inreview: '#f59e0b',
done: '#22c55e',
cancelled: '#ef4444'
}
export function TaskKanbanBoard({ tasks, onDragEnd, onEditTask, onDeleteTask, onViewTaskDetails }: TaskKanbanBoardProps) {
const groupTasksByStatus = () => {
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>
// Initialize groups for all possible statuses
allTaskStatuses.forEach(status => {
groups[status] = []
})
tasks.forEach(task => {
// Convert old capitalized status to lowercase if needed
const normalizedStatus = task.status.toLowerCase() as TaskStatus
if (groups[normalizedStatus]) {
groups[normalizedStatus].push(task)
} else {
// Default to todo if status doesn't match any expected value
groups['todo'].push(task)
}
})
return groups
}
return (
<KanbanProvider onDragEnd={onDragEnd}>
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
<KanbanBoard key={status} id={status as TaskStatus}>
<KanbanHeader
name={statusLabels[status as TaskStatus]}
color={statusBoardColors[status as TaskStatus]}
/>
<KanbanCards>
{statusTasks.map((task, index) => (
<TaskCard
key={task.id}
task={task}
index={index}
status={status}
onEdit={onEditTask}
onDelete={onDeleteTask}
onViewDetails={onViewTaskDetails}
/>
))}
</KanbanCards>
</KanbanBoard>
))}
</KanbanProvider>
)
}

View File

@@ -0,0 +1,5 @@
export { TaskCreateDialog } from './TaskCreateDialog'
export { TaskEditDialog } from './TaskEditDialog'
export { TaskDetailsDialog } from './TaskDetailsDialog'
export { TaskCard } from './TaskCard'
export { TaskKanbanBoard } from './TaskKanbanBoard'

View File

@@ -0,0 +1,248 @@
import React, { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Folder, FolderOpen, File, AlertCircle, Home, ChevronUp } from 'lucide-react'
import { makeAuthenticatedRequest } from '@/lib/auth'
import { DirectoryEntry } from 'shared/types'
interface FolderPickerProps {
open: boolean
onClose: () => void
onSelect: (path: string) => void
value?: string
title?: string
description?: string
}
export function FolderPicker({
open,
onClose,
onSelect,
value = '',
title = 'Select Folder',
description = 'Choose a folder for your project'
}: FolderPickerProps) {
const [currentPath, setCurrentPath] = useState<string>('')
const [entries, setEntries] = useState<DirectoryEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [manualPath, setManualPath] = useState(value)
useEffect(() => {
if (open) {
setManualPath(value)
loadDirectory()
}
}, [open, value])
const loadDirectory = async (path?: string) => {
setLoading(true)
setError('')
try {
const queryParam = path ? `?path=${encodeURIComponent(path)}` : ''
const response = await makeAuthenticatedRequest(`/api/filesystem/list${queryParam}`)
if (!response.ok) {
throw new Error('Failed to load directory')
}
const data = await response.json()
if (data.success) {
setEntries(data.data || [])
const newPath = path || data.message || ''
setCurrentPath(newPath)
// Update manual path if we have a specific path (not for initial home directory load)
if (path) {
setManualPath(newPath)
}
} else {
setError(data.message || 'Failed to load directory')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directory')
} finally {
setLoading(false)
}
}
const handleFolderClick = (entry: DirectoryEntry) => {
if (entry.is_directory) {
loadDirectory(entry.path)
setManualPath(entry.path) // Auto-populate the manual path field
}
}
const handleParentDirectory = () => {
const parentPath = currentPath.split('/').slice(0, -1).join('/')
const newPath = parentPath || '/'
loadDirectory(newPath)
setManualPath(newPath)
}
const handleHomeDirectory = () => {
loadDirectory()
// Don't set manual path here since home directory path varies by system
}
const handleManualPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setManualPath(e.target.value)
}
const handleManualPathSubmit = () => {
loadDirectory(manualPath)
}
const handleSelectCurrent = () => {
onSelect(manualPath || currentPath)
onClose()
}
const handleSelectManual = () => {
onSelect(manualPath)
onClose()
}
const handleClose = () => {
setError('')
onClose()
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-[600px] w-full h-[500px] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex-1 flex flex-col space-y-4 overflow-hidden">
{/* Legend */}
<div className="text-xs text-muted-foreground border-b pb-2">
Click folder names to navigate Use action buttons to select
</div>
{/* Manual path input */}
<div className="space-y-2">
<div className="text-sm font-medium">Enter path manually:</div>
<div className="flex space-x-2 min-w-0">
<Input
value={manualPath}
onChange={handleManualPathChange}
placeholder="/path/to/your/project"
className="flex-1 min-w-0"
/>
<Button
onClick={handleManualPathSubmit}
variant="outline"
size="sm"
className="flex-shrink-0"
>
Go
</Button>
</div>
</div>
{/* Navigation */}
<div className="flex items-center space-x-2 min-w-0">
<Button
onClick={handleHomeDirectory}
variant="outline"
size="sm"
className="flex-shrink-0"
>
<Home className="h-4 w-4" />
</Button>
<Button
onClick={handleParentDirectory}
variant="outline"
size="sm"
disabled={!currentPath || currentPath === '/'}
className="flex-shrink-0"
>
<ChevronUp className="h-4 w-4" />
</Button>
<div className="text-sm text-muted-foreground flex-1 truncate min-w-0">
{currentPath || 'Home'}
</div>
<Button
onClick={handleSelectCurrent}
variant="outline"
size="sm"
disabled={!currentPath}
className="flex-shrink-0"
>
Select Current
</Button>
</div>
{/* Directory listing */}
<div className="flex-1 border rounded-md overflow-auto">
{loading ? (
<div className="p-4 text-center text-muted-foreground">
Loading...
</div>
) : error ? (
<Alert variant="destructive" className="m-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : entries.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
No folders found
</div>
) : (
<div className="p-2">
{entries.map((entry, index) => (
<div
key={index}
className={`flex items-center space-x-2 p-2 rounded cursor-pointer hover:bg-accent ${
!entry.is_directory ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => entry.is_directory && handleFolderClick(entry)}
title={entry.name} // Show full name on hover
>
{entry.is_directory ? (
entry.is_git_repo ? (
<FolderOpen className="h-4 w-4 text-green-600 flex-shrink-0" />
) : (
<Folder className="h-4 w-4 text-blue-600 flex-shrink-0" />
)
) : (
<File className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<span className="text-sm flex-1 truncate min-w-0">{entry.name}</span>
{entry.is_git_repo && (
<span className="text-xs text-green-600 bg-green-100 px-2 py-1 rounded flex-shrink-0">
git repo
</span>
)}
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
>
Cancel
</Button>
<Button
onClick={handleSelectManual}
disabled={!manualPath.trim()}
>
Select Path
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,17 +1,20 @@
'use client';
"use client";
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import {
DndContext,
PointerSensor,
rectIntersection,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import type { ReactNode } from 'react';
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent } from "@dnd-kit/core";
import type { ReactNode } from "react";
export type { DragEndEvent } from '@dnd-kit/core';
export type { DragEndEvent } from "@dnd-kit/core";
export type Status = {
id: string;
@@ -28,7 +31,7 @@ export type Feature = {
};
export type KanbanBoardProps = {
id: Status['id'];
id: Status["id"];
children: ReactNode;
className?: string;
};
@@ -39,8 +42,8 @@ export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
return (
<div
className={cn(
'flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all',
isOver ? 'outline-primary' : 'outline-transparent',
"flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all",
isOver ? "outline-primary" : "outline-transparent",
className
)}
ref={setNodeRef}
@@ -50,11 +53,12 @@ export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
);
};
export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
export type KanbanCardProps = Pick<Feature, "id" | "name"> & {
index: number;
parent: string;
children?: ReactNode;
className?: string;
onClick?: () => void;
};
export const KanbanCard = ({
@@ -64,6 +68,7 @@ export const KanbanCard = ({
parent,
children,
className,
onClick,
}: KanbanCardProps) => {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
@@ -74,18 +79,19 @@ export const KanbanCard = ({
return (
<Card
className={cn(
'rounded-md p-3 shadow-sm',
isDragging && 'cursor-grabbing',
"rounded-md p-3 shadow-sm",
isDragging && "cursor-grabbing",
className
)}
style={{
transform: transform
? `translateX(${transform.x}px) translateY(${transform.y}px)`
: 'none',
: "none",
}}
{...listeners}
{...attributes}
ref={setNodeRef}
onClick={onClick}
>
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
</Card>
@@ -98,7 +104,7 @@ export type KanbanCardsProps = {
};
export const KanbanCards = ({ children, className }: KanbanCardsProps) => (
<div className={cn('flex flex-1 flex-col gap-2', className)}>{children}</div>
<div className={cn("flex flex-1 flex-col gap-2", className)}>{children}</div>
);
export type KanbanHeaderProps =
@@ -106,16 +112,16 @@ export type KanbanHeaderProps =
children: ReactNode;
}
| {
name: Status['name'];
color: Status['color'];
name: Status["name"];
color: Status["color"];
className?: string;
};
export const KanbanHeader = (props: KanbanHeaderProps) =>
'children' in props ? (
"children" in props ? (
props.children
) : (
<div className={cn('flex shrink-0 items-center gap-2', props.className)}>
<div className={cn("flex shrink-0 items-center gap-2", props.className)}>
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: props.color }}
@@ -134,12 +140,27 @@ export const KanbanProvider = ({
children,
onDragEnd,
className,
}: KanbanProviderProps) => (
<DndContext collisionDetection={rectIntersection} onDragEnd={onDragEnd}>
<div
className={cn('grid w-full auto-cols-fr grid-flow-col gap-4', className)}
}: KanbanProviderProps) => {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
return (
<DndContext
collisionDetection={rectIntersection}
onDragEnd={onDragEnd}
sensors={sensors}
>
{children}
</div>
</DndContext>
);
<div
className={cn(
"grid w-full auto-cols-fr grid-flow-col gap-4",
className
)}
>
{children}
</div>
</DndContext>
);
};

View File

@@ -2,39 +2,14 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
import { ArrowLeft, Plus } from 'lucide-react'
import { makeAuthenticatedRequest } from '@/lib/auth'
import {
KanbanProvider,
KanbanBoard,
KanbanHeader,
KanbanCards,
KanbanCard,
type DragEndEvent
} from '@/components/ui/shadcn-io/kanban'
import { TaskCreateDialog } from '@/components/tasks/TaskCreateDialog'
import { TaskEditDialog } from '@/components/tasks/TaskEditDialog'
import { TaskDetailsDialog } from '@/components/tasks/TaskDetailsDialog'
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard'
import type { TaskStatus } from 'shared/types'
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban'
interface Task {
id: string
@@ -62,24 +37,7 @@ interface ApiResponse<T> {
// All possible task statuses from shared types
const allTaskStatuses: TaskStatus[] = ['todo', 'inprogress', 'inreview', 'done', 'cancelled']
const statusLabels: Record<TaskStatus, string> = {
todo: 'To Do',
inprogress: 'In Progress',
inreview: 'In Review',
done: 'Done',
cancelled: 'Cancelled'
}
const statusBoardColors: Record<TaskStatus, string> = {
todo: '#64748b',
inprogress: '#3b82f6',
inreview: '#f59e0b',
done: '#22c55e',
cancelled: '#ef4444'
}
export function ProjectTasks() {
const { projectId } = useParams<{ projectId: string }>()
@@ -91,13 +49,9 @@ export function ProjectTasks() {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
const [isTaskDetailsDialogOpen, setIsTaskDetailsDialogOpen] = useState(false)
// Form states
const [newTaskTitle, setNewTaskTitle] = useState('')
const [newTaskDescription, setNewTaskDescription] = useState('')
const [editTaskTitle, setEditTaskTitle] = useState('')
const [editTaskDescription, setEditTaskDescription] = useState('')
const [editTaskStatus, setEditTaskStatus] = useState<Task['status']>('todo')
useEffect(() => {
if (projectId) {
@@ -144,24 +98,19 @@ export function ProjectTasks() {
}
}
const createTask = async () => {
if (!newTaskTitle.trim()) return
const handleCreateTask = async (title: string, description: string) => {
try {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`, {
method: 'POST',
body: JSON.stringify({
project_id: projectId,
title: newTaskTitle,
description: newTaskDescription || null
title,
description: description || null
})
})
if (response.ok) {
await fetchTasks()
setNewTaskTitle('')
setNewTaskDescription('')
setIsCreateDialogOpen(false)
} else {
setError('Failed to create task')
}
@@ -170,23 +119,22 @@ export function ProjectTasks() {
}
}
const updateTask = async () => {
if (!editingTask || !editTaskTitle.trim()) return
const handleUpdateTask = async (title: string, description: string, status: TaskStatus) => {
if (!editingTask) return
try {
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
method: 'PUT',
body: JSON.stringify({
title: editTaskTitle,
description: editTaskDescription || null,
status: editTaskStatus
title,
description: description || null,
status
})
})
if (response.ok) {
await fetchTasks()
setEditingTask(null)
setIsEditDialogOpen(false)
} else {
setError('Failed to update task')
}
@@ -195,7 +143,7 @@ export function ProjectTasks() {
}
}
const deleteTask = async (taskId: string) => {
const handleDeleteTask = async (taskId: string) => {
if (!confirm('Are you sure you want to delete this task?')) return
try {
@@ -213,14 +161,16 @@ export function ProjectTasks() {
}
}
const openEditDialog = (task: Task) => {
const handleEditTask = (task: Task) => {
setEditingTask(task)
setEditTaskTitle(task.title)
setEditTaskDescription(task.description || '')
setEditTaskStatus(task.status)
setIsEditDialogOpen(true)
}
const handleViewTaskDetails = (task: Task) => {
setSelectedTask(task)
setIsTaskDetailsDialogOpen(true)
}
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
@@ -264,27 +214,7 @@ export function ProjectTasks() {
}
}
const groupTasksByStatus = () => {
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>
// Initialize groups for all possible statuses
allTaskStatuses.forEach(status => {
groups[status] = []
})
tasks.forEach(task => {
// Convert old capitalized status to lowercase if needed
const normalizedStatus = task.status.toLowerCase() as TaskStatus
if (groups[normalizedStatus]) {
groups[normalizedStatus].push(task)
} else {
// Default to todo if status doesn't match any expected value
groups['todo'].push(task)
}
})
return groups
}
if (loading) {
return <div className="text-center py-8">Loading tasks...</div>
@@ -324,43 +254,11 @@ export function ProjectTasks() {
</Button>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
placeholder="Enter task title"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={newTaskDescription}
onChange={(e) => setNewTaskDescription(e.target.value)}
placeholder="Enter task description (optional)"
rows={3}
/>
</div>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={createTask}>Create Task</Button>
</div>
</div>
</DialogContent>
</Dialog>
<TaskCreateDialog
isOpen={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
onCreateTask={handleCreateTask}
/>
{/* Tasks View */}
{tasks.length === 0 ? (
@@ -377,139 +275,29 @@ export function ProjectTasks() {
</CardContent>
</Card>
) : (
<KanbanProvider onDragEnd={handleDragEnd}>
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
<KanbanBoard key={status} id={status as Task['status']}>
<KanbanHeader
name={statusLabels[status as Task['status']]}
color={statusBoardColors[status as Task['status']]}
/>
<KanbanCards>
{statusTasks.map((task, index) => (
<KanbanCard
key={task.id}
id={task.id}
name={task.title}
index={index}
parent={status}
>
<div className="space-y-2">
<div className="flex items-start justify-between">
<div
className="flex-1 cursor-pointer pr-2"
onClick={() => openEditDialog(task)}
>
<h4 className="font-medium text-sm">
{task.title}
</h4>
</div>
<div
className="flex-shrink-0"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-gray-100"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEditDialog(task)}>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteTask(task.id)}
className="text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{task.description && (
<div
className="cursor-pointer"
onClick={() => openEditDialog(task)}
>
<p className="text-xs text-muted-foreground">
{task.description}
</p>
</div>
)}
</div>
</KanbanCard>
))}
</KanbanCards>
</KanbanBoard>
))}
</KanbanProvider>
<TaskKanbanBoard
tasks={tasks}
onDragEnd={handleDragEnd}
onEditTask={handleEditTask}
onDeleteTask={handleDeleteTask}
onViewTaskDetails={handleViewTaskDetails}
/>
)}
{/* Edit Task Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Task</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-title">Title</Label>
<Input
id="edit-title"
value={editTaskTitle}
onChange={(e) => setEditTaskTitle(e.target.value)}
placeholder="Enter task title"
/>
</div>
<div>
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
value={editTaskDescription}
onChange={(e) => setEditTaskDescription(e.target.value)}
placeholder="Enter task description (optional)"
rows={3}
/>
</div>
<div>
<Label htmlFor="edit-status">Status</Label>
<Select
value={editTaskStatus}
onValueChange={(value) => setEditTaskStatus(value as Task['status'])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">To Do</SelectItem>
<SelectItem value="inprogress">In Progress</SelectItem>
<SelectItem value="inreview">In Review</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={updateTask}>Update Task</Button>
</div>
</div>
</DialogContent>
</Dialog>
<TaskEditDialog
isOpen={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
task={editingTask}
onUpdateTask={handleUpdateTask}
/>
<TaskDetailsDialog
isOpen={isTaskDetailsDialogOpen}
onOpenChange={setIsTaskDetailsDialogOpen}
task={selectedTask}
projectId={projectId!}
onError={setError}
/>
</div>
)
}