Main branch Resync on w/ gitea. v0.1.6 #1
@@ -1,6 +1,7 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import ProjectList from './pages/ProjectList'
|
||||
import ProjectView from './pages/ProjectView'
|
||||
import SearchBar from './components/SearchBar'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -15,6 +16,7 @@ function App() {
|
||||
<span className="ml-2 text-xs text-gray-600">v0.1.3</span>
|
||||
</h1>
|
||||
</div>
|
||||
<SearchBar />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Edit2, Trash2, Check, X } from 'lucide-react'
|
||||
import { Plus, Check, X, Flag } from 'lucide-react'
|
||||
import {
|
||||
getProjectTasks,
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask
|
||||
} from '../utils/api'
|
||||
import { formatTime } from '../utils/format'
|
||||
import TaskMenu from './TaskMenu'
|
||||
|
||||
const STATUSES = [
|
||||
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
|
||||
@@ -14,6 +16,16 @@ const STATUSES = [
|
||||
{ key: 'done', label: 'Done', color: 'border-green-500' }
|
||||
]
|
||||
|
||||
const FLAG_COLORS = {
|
||||
red: 'bg-red-500',
|
||||
orange: 'bg-orange-500',
|
||||
yellow: 'bg-yellow-500',
|
||||
green: 'bg-green-500',
|
||||
blue: 'bg-blue-500',
|
||||
purple: 'bg-purple-500',
|
||||
pink: 'bg-pink-500'
|
||||
}
|
||||
|
||||
function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editTitle, setEditTitle] = useState(task.title)
|
||||
@@ -78,26 +90,56 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
||||
<>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-gray-200 text-sm">{task.title}</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{/* Flag indicator */}
|
||||
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||
<Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
||||
)}
|
||||
<span className="text-gray-200">{task.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Parent task context */}
|
||||
{parentTask && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
↳ subtask of: <span className="text-cyber-orange">{parentTask.title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata row */}
|
||||
{(task.estimated_minutes || (task.tags && task.tags.length > 0)) && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{/* Time estimate */}
|
||||
{task.estimated_minutes && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Clock size={11} />
|
||||
<span>{formatTime(task.estimated_minutes)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{task.tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center px-1.5 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-gray-400 hover:text-gray-200"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-gray-600 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<TaskMenu
|
||||
task={task}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={handleDelete}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
262
frontend/src/components/SearchBar.jsx
Normal file
262
frontend/src/components/SearchBar.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Search, X, Flag } from 'lucide-react'
|
||||
import { searchTasks, getProjects } from '../utils/api'
|
||||
import { formatTime } from '../utils/format'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const FLAG_COLORS = {
|
||||
red: 'text-red-500',
|
||||
orange: 'text-orange-500',
|
||||
yellow: 'text-yellow-500',
|
||||
green: 'text-green-500',
|
||||
blue: 'text-blue-500',
|
||||
purple: 'text-purple-500',
|
||||
pink: 'text-pink-500'
|
||||
}
|
||||
|
||||
function SearchBar() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [projects, setProjects] = useState([])
|
||||
const [selectedProjects, setSelectedProjects] = useState([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
const [showProjectFilter, setShowProjectFilter] = useState(false)
|
||||
const searchRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (searchRef.current && !searchRef.current.contains(event.target)) {
|
||||
setShowResults(false)
|
||||
setShowProjectFilter(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const data = await getProjects()
|
||||
setProjects(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async (searchQuery) => {
|
||||
if (!searchQuery.trim()) {
|
||||
setResults([])
|
||||
setShowResults(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const projectIds = selectedProjects.length > 0 ? selectedProjects : null
|
||||
const data = await searchTasks(searchQuery, projectIds)
|
||||
setResults(data)
|
||||
setShowResults(true)
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err)
|
||||
setResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQueryChange = (e) => {
|
||||
const newQuery = e.target.value
|
||||
setQuery(newQuery)
|
||||
}
|
||||
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setResults([])
|
||||
setShowResults(false)
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
handleSearch(query)
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [query, selectedProjects])
|
||||
|
||||
const toggleProjectFilter = (projectId) => {
|
||||
setSelectedProjects(prev => {
|
||||
if (prev.includes(projectId)) {
|
||||
return prev.filter(id => id !== projectId)
|
||||
} else {
|
||||
return [...prev, projectId]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleTaskClick = (task) => {
|
||||
navigate(`/project/${task.project_id}`)
|
||||
setShowResults(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setShowResults(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={searchRef}>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onFocus={() => query && setShowResults(true)}
|
||||
placeholder="Search tasks..."
|
||||
className="w-64 pl-9 pr-8 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange placeholder-gray-500"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Filter Button */}
|
||||
{projects.length > 1 && (
|
||||
<button
|
||||
onClick={() => setShowProjectFilter(!showProjectFilter)}
|
||||
className={`px-3 py-2 text-sm rounded border ${
|
||||
selectedProjects.length > 0
|
||||
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
|
||||
: 'bg-cyber-darker border-cyber-orange/30 text-gray-400'
|
||||
} hover:border-cyber-orange transition-colors`}
|
||||
>
|
||||
{selectedProjects.length > 0 ? `${selectedProjects.length} Project(s)` : 'All Projects'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Filter Dropdown */}
|
||||
{showProjectFilter && (
|
||||
<div className="absolute top-12 right-0 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg max-h-80 overflow-y-auto">
|
||||
<div className="p-2">
|
||||
<div className="text-xs text-gray-400 px-2 py-1 mb-1">Filter by projects:</div>
|
||||
{projects.map(project => (
|
||||
<label
|
||||
key={project.id}
|
||||
className="flex items-center gap-2 px-2 py-2 hover:bg-cyber-darker rounded cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProjects.includes(project.id)}
|
||||
onChange={() => toggleProjectFilter(project.id)}
|
||||
className="rounded border-cyber-orange/50 bg-cyber-darker text-cyber-orange focus:ring-cyber-orange focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">{project.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{selectedProjects.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedProjects([])
|
||||
if (query) handleSearch(query)
|
||||
}}
|
||||
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
|
||||
>
|
||||
Clear Filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
{showResults && (
|
||||
<div className="absolute top-12 left-0 z-50 w-96 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg max-h-96 overflow-y-auto">
|
||||
{isSearching ? (
|
||||
<div className="p-4 text-center text-gray-400 text-sm">Searching...</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">No results found</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{results.map(task => {
|
||||
const project = projects.find(p => p.id === task.project_id)
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
className="w-full text-left px-2 py-2 hover:bg-cyber-darker rounded transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Flag */}
|
||||
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||
<Flag size={12} className={`mt-0.5 ${FLAG_COLORS[task.flag_color]}`} fill="currentColor" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title */}
|
||||
<div className="text-sm text-gray-200 truncate">{task.title}</div>
|
||||
|
||||
{/* Project name */}
|
||||
{project && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
in: <span className="text-cyber-orange">{project.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{(task.estimated_minutes || (task.tags && task.tags.length > 0)) && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{task.estimated_minutes && (
|
||||
<span className="text-xs text-gray-500">{formatTime(task.estimated_minutes)}</span>
|
||||
)}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{task.tags.slice(0, 3).map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-block px-1 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{task.tags.length > 3 && (
|
||||
<span className="text-xs text-gray-500">+{task.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBar
|
||||
259
frontend/src/components/TaskMenu.jsx
Normal file
259
frontend/src/components/TaskMenu.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check } from 'lucide-react'
|
||||
import { updateTask } from '../utils/api'
|
||||
|
||||
const FLAG_COLORS = [
|
||||
{ name: 'red', color: 'bg-red-500' },
|
||||
{ name: 'orange', color: 'bg-orange-500' },
|
||||
{ name: 'yellow', color: 'bg-yellow-500' },
|
||||
{ name: 'green', color: 'bg-green-500' },
|
||||
{ name: 'blue', color: 'bg-blue-500' },
|
||||
{ name: 'purple', color: 'bg-purple-500' },
|
||||
{ name: 'pink', color: 'bg-pink-500' }
|
||||
]
|
||||
|
||||
function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showTimeEdit, setShowTimeEdit] = useState(false)
|
||||
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
||||
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
||||
const [editTime, setEditTime] = useState(task.estimated_minutes || '')
|
||||
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
|
||||
const menuRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
setIsOpen(false)
|
||||
setShowTimeEdit(false)
|
||||
setShowTagsEdit(false)
|
||||
setShowFlagEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleUpdateTime = async () => {
|
||||
try {
|
||||
const minutes = editTime ? parseInt(editTime) : null
|
||||
await updateTask(task.id, { estimated_minutes: minutes })
|
||||
setShowTimeEdit(false)
|
||||
setIsOpen(false)
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateTags = async () => {
|
||||
try {
|
||||
const tags = editTags
|
||||
? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0)
|
||||
: null
|
||||
await updateTask(task.id, { tags })
|
||||
setShowTagsEdit(false)
|
||||
setIsOpen(false)
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateFlag = async (color) => {
|
||||
try {
|
||||
await updateTask(task.id, { flag_color: color })
|
||||
setShowFlagEdit(false)
|
||||
setIsOpen(false)
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearFlag = async () => {
|
||||
try {
|
||||
await updateTask(task.id, { flag_color: null })
|
||||
setShowFlagEdit(false)
|
||||
setIsOpen(false)
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-200 p-1"
|
||||
title="More options"
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-8 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg overflow-hidden">
|
||||
{/* Time Edit */}
|
||||
{showTimeEdit ? (
|
||||
<div className="p-3 border-b border-cyber-orange/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-cyber-orange" />
|
||||
<span className="text-sm text-gray-300">Time Estimate (minutes)</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editTime}
|
||||
onChange={(e) => setEditTime(e.target.value)}
|
||||
placeholder="Minutes"
|
||||
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpdateTime}
|
||||
className="text-green-400 hover:text-green-300"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTimeEdit(false)}
|
||||
className="text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowTimeEdit(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span>Set Time Estimate</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tags Edit */}
|
||||
{showTagsEdit ? (
|
||||
<div className="p-3 border-b border-cyber-orange/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Tag size={14} className="text-cyber-orange" />
|
||||
<span className="text-sm text-gray-300">Tags (comma-separated)</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editTags}
|
||||
onChange={(e) => setEditTags(e.target.value)}
|
||||
placeholder="coding, bug-fix"
|
||||
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpdateTags}
|
||||
className="text-green-400 hover:text-green-300"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTagsEdit(false)}
|
||||
className="text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowTagsEdit(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||
>
|
||||
<Tag size={14} />
|
||||
<span>Edit Tags</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Flag Color Edit */}
|
||||
{showFlagEdit ? (
|
||||
<div className="p-3 border-b border-cyber-orange/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Flag size={14} className="text-cyber-orange" />
|
||||
<span className="text-sm text-gray-300">Flag Color</span>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{FLAG_COLORS.map(({ name, color }) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleUpdateFlag(name)}
|
||||
className={`w-6 h-6 ${color} rounded hover:ring-2 hover:ring-cyber-orange transition-all`}
|
||||
title={name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearFlag}
|
||||
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
|
||||
>
|
||||
Clear Flag
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowFlagEdit(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||
>
|
||||
<Flag size={14} />
|
||||
<span>Set Flag Color</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit Title */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
<span>Edit Title</span>
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-red-400 hover:text-red-300 text-sm border-t border-cyber-orange/20"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Delete Task</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskMenu
|
||||
@@ -3,10 +3,9 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Check,
|
||||
X
|
||||
X,
|
||||
Flag
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
getProjectTaskTree,
|
||||
@@ -14,6 +13,8 @@ import {
|
||||
updateTask,
|
||||
deleteTask
|
||||
} from '../utils/api'
|
||||
import { formatTime } from '../utils/format'
|
||||
import TaskMenu from './TaskMenu'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
backlog: 'text-gray-400',
|
||||
@@ -29,6 +30,16 @@ const STATUS_LABELS = {
|
||||
done: 'Done'
|
||||
}
|
||||
|
||||
const FLAG_COLORS = {
|
||||
red: 'bg-red-500',
|
||||
orange: 'bg-orange-500',
|
||||
yellow: 'bg-yellow-500',
|
||||
green: 'bg-green-500',
|
||||
blue: 'bg-blue-500',
|
||||
purple: 'bg-purple-500',
|
||||
pink: 'bg-pink-500'
|
||||
}
|
||||
|
||||
function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
@@ -139,12 +150,45 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Flag indicator */}
|
||||
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||
<Flag size={14} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
||||
)}
|
||||
<span className="text-gray-200">{task.title}</span>
|
||||
<span className={`ml-3 text-xs ${STATUS_COLORS[task.status]}`}>
|
||||
<span className={`ml-2 text-xs ${STATUS_COLORS[task.status]}`}>
|
||||
{STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metadata row */}
|
||||
{(task.estimated_minutes || (task.tags && task.tags.length > 0)) && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{/* Time estimate */}
|
||||
{task.estimated_minutes && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Clock size={12} />
|
||||
<span>{formatTime(task.estimated_minutes)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{task.tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@@ -154,20 +198,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-gray-400 hover:text-gray-200"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-gray-600 hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<TaskMenu
|
||||
task={task}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={handleDelete}
|
||||
onEdit={() => setIsEditing(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user