diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 79d44d8..e01dc41 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { v0.1.3 + diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index 9bc9f5f..07c7b09 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -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 }) { <>
-
{task.title}
+
+ {/* Flag indicator */} + {task.flag_color && FLAG_COLORS[task.flag_color] && ( + + )} + {task.title} +
+ + {/* Parent task context */} {parentTask && (
↳ subtask of: {parentTask.title}
)} + + {/* Metadata row */} + {(task.estimated_minutes || (task.tags && task.tags.length > 0)) && ( +
+ {/* Time estimate */} + {task.estimated_minutes && ( +
+ + {formatTime(task.estimated_minutes)} +
+ )} + + {/* Tags */} + {task.tags && task.tags.length > 0 && ( +
+ {task.tags.map((tag, idx) => ( + + {tag} + + ))} +
+ )} +
+ )}
+
- - + setIsEditing(true)} + />
diff --git a/frontend/src/components/SearchBar.jsx b/frontend/src/components/SearchBar.jsx new file mode 100644 index 0000000..eeaea18 --- /dev/null +++ b/frontend/src/components/SearchBar.jsx @@ -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 ( +
+
+ {/* Search Input */} +
+ + 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 && ( + + )} +
+ + {/* Project Filter Button */} + {projects.length > 1 && ( + + )} +
+ + {/* Project Filter Dropdown */} + {showProjectFilter && ( +
+
+
Filter by projects:
+ {projects.map(project => ( + + ))} + {selectedProjects.length > 0 && ( + + )} +
+
+ )} + + {/* Search Results */} + {showResults && ( +
+ {isSearching ? ( +
Searching...
+ ) : results.length === 0 ? ( +
No results found
+ ) : ( +
+
+ {results.length} result{results.length !== 1 ? 's' : ''} +
+ {results.map(task => { + const project = projects.find(p => p.id === task.project_id) + return ( + + ) + })} +
+ )} +
+ )} +
+ ) +} + +export default SearchBar diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx new file mode 100644 index 0000000..4224864 --- /dev/null +++ b/frontend/src/components/TaskMenu.jsx @@ -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 ( +
+ + + {isOpen && ( +
+ {/* Time Edit */} + {showTimeEdit ? ( +
+
+ + Time Estimate (minutes) +
+
+ 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()} + /> + + +
+
+ ) : ( + + )} + + {/* Tags Edit */} + {showTagsEdit ? ( +
+
+ + Tags (comma-separated) +
+
+ 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()} + /> + + +
+
+ ) : ( + + )} + + {/* Flag Color Edit */} + {showFlagEdit ? ( +
+
+ + Flag Color +
+
+ {FLAG_COLORS.map(({ name, color }) => ( +
+ +
+ ) : ( + + )} + + {/* Edit Title */} + + + {/* Delete */} + +
+ )} +
+ ) +} + +export default TaskMenu diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx index 0c3a9b9..69ba24f 100644 --- a/frontend/src/components/TreeView.jsx +++ b/frontend/src/components/TreeView.jsx @@ -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,10 +150,43 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) { ) : ( <>
- {task.title} - - {STATUS_LABELS[task.status]} - +
+ {/* Flag indicator */} + {task.flag_color && FLAG_COLORS[task.flag_color] && ( + + )} + {task.title} + + {STATUS_LABELS[task.status]} + +
+ + {/* Metadata row */} + {(task.estimated_minutes || (task.tags && task.tags.length > 0)) && ( +
+ {/* Time estimate */} + {task.estimated_minutes && ( +
+ + {formatTime(task.estimated_minutes)} +
+ )} + + {/* Tags */} + {task.tags && task.tags.length > 0 && ( +
+ {task.tags.map((tag, idx) => ( + + {tag} + + ))} +
+ )} +
+ )}
{/* Actions */} @@ -154,20 +198,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) { > - - + setIsEditing(true)} + /> )}