diff --git a/src/aggregator/web/app.tsx b/src/aggregator/web/app.tsx index 415f3b4..ddb6707 100644 --- a/src/aggregator/web/app.tsx +++ b/src/aggregator/web/app.tsx @@ -38,7 +38,9 @@ function App() { const [connected, setConnected] = useState(false); const [lastUpdate, setLastUpdate] = useState(null); const [filter, setFilter] = useState(""); - const [selectedProject, setSelectedProject] = useState(null); + const [selectedProjects, setSelectedProjects] = useState | null>(null); // null = all + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownRef = useRef(null); const [actionError, setActionError] = useState(null); const [draggedTask, setDraggedTask] = useState(null); const [dragOverColumn, setDragOverColumn] = useState(null); @@ -205,13 +207,52 @@ function App() { }; }, [connectWebSocket]); + // Close dropdown on outside click + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + }, []); + + const isProjectSelected = (name: string) => selectedProjects === null || selectedProjects.has(name); + + const toggleProject = (name: string) => { + const activeNames = projects.filter((p) => tasks.some((t) => t.projectName === p.name)).map((p) => p.name); + if (selectedProjects === null) { + const next = new Set(activeNames); + next.delete(name); + setSelectedProjects(next); + } else if (selectedProjects.has(name)) { + const next = new Set(selectedProjects); + next.delete(name); + setSelectedProjects(next); + } else { + const next = new Set(selectedProjects); + next.add(name); + setSelectedProjects(next.size === activeNames.length ? null : next); + } + }; + + const activeProjects = projects.filter((p) => tasks.some((t) => t.projectName === p.name)); + + const dropdownLabel = (() => { + if (selectedProjects === null) return "All Projects"; + if (selectedProjects.size === 0) return "No Projects"; + if (selectedProjects.size === 1) return [...selectedProjects][0]; + return `${selectedProjects.size} Projects`; + })(); + // Group tasks by status const statuses = ["To Do", "In Progress", "Done", "Won't Do"]; const tasksByStatus = statuses.reduce( (acc, status) => { acc[status] = tasks.filter((t) => { const statusMatch = t.status.toLowerCase() === status.toLowerCase(); - const projectMatch = !selectedProject || t.projectName === selectedProject; + const projectMatch = selectedProjects === null || selectedProjects.has(t.projectName); const filterMatch = !filter || t.title.toLowerCase().includes(filter.toLowerCase()) || @@ -348,46 +389,150 @@ function App() { minWidth: "200px", }} /> - - {projects.map((project) => ( - - ))} + > +
+ + +
+ {activeProjects.map((project) => { + const selected = isProjectSelected(project.name); + return ( +
toggleProject(project.name)} + style={{ + display: "flex", + alignItems: "center", + gap: "0.5rem", + padding: "0.5rem 0.75rem", + cursor: "pointer", + fontSize: "0.875rem", + background: "transparent", + }} + onMouseEnter={(e) => (e.currentTarget.style.background = "#334155")} + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} + > + + {selected && ( + + + + )} + + + {project.name} +
+ ); + })} + + )} + {/* View Content */} @@ -486,7 +631,7 @@ function App() { @@ -842,7 +987,7 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses, onDragStart, interface DependencyGraphProps { tasks: AggregatedTask[]; projects: Project[]; - selectedProject: string | null; + selectedProjects: Set | null; filter: string; showCompleted: boolean; } @@ -873,7 +1018,7 @@ function isCompleted(status: string): boolean { return lower.includes("done") || lower.includes("complete"); } -function DependencyGraph({ tasks, projects, selectedProject, filter, showCompleted }: DependencyGraphProps) { +function DependencyGraph({ tasks, projects, selectedProjects, filter, showCompleted }: DependencyGraphProps) { const svgRef = useRef(null); const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); @@ -882,11 +1027,11 @@ function DependencyGraph({ tasks, projects, selectedProject, filter, showComplet const filteredTasks = useMemo(() => { return tasks.filter((t) => { if (!showCompleted && isCompleted(t.status)) return false; - if (selectedProject && t.projectName !== selectedProject) return false; + if (selectedProjects !== null && !selectedProjects.has(t.projectName)) return false; if (filter && !t.title.toLowerCase().includes(filter.toLowerCase())) return false; return true; }); - }, [tasks, showCompleted, selectedProject, filter]); + }, [tasks, showCompleted, selectedProjects, filter]); // Build graph data - note: aggregated tasks don't have dependencies in YAML // So we'll just show all tasks as nodes without edges for now diff --git a/src/aggregator/web/index.html b/src/aggregator/web/index.html index b22ed38..e72acc0 100644 --- a/src/aggregator/web/index.html +++ b/src/aggregator/web/index.html @@ -48,7 +48,34 @@ color: #e2e8f0; min-width: 200px; } - .project-btn, .action-btn { + .action-btn { + padding: 0.5rem 1rem; + border-radius: 0.375rem; + border: 1px solid #334155; + background: #22c55e; + border-color: #16a34a; + color: #e2e8f0; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + } + .action-btn:hover { background: #16a34a; } + .project-dot { + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; + } + + /* Project Dropdown Filter */ + .project-dropdown { + position: relative; + } + .project-dropdown-trigger { padding: 0.5rem 1rem; border-radius: 0.375rem; border: 1px solid #334155; @@ -59,14 +86,97 @@ align-items: center; gap: 0.5rem; font-size: 0.875rem; + min-width: 180px; + user-select: none; } - .project-btn.active { border-width: 2px; border-color: #3b82f6; background: #1e40af; } - .action-btn { background: #22c55e; border-color: #16a34a; font-weight: 500; } - .action-btn:hover { background: #16a34a; } - .project-dot { - width: 0.75rem; - height: 0.75rem; - border-radius: 50%; + .project-dropdown-trigger:hover { + border-color: #475569; + background: #1e293b; + } + .project-dropdown-trigger .arrow { + margin-left: auto; + font-size: 0.625rem; + transition: transform 0.2s; + } + .project-dropdown.open .arrow { + transform: rotate(180deg); + } + .project-dropdown-trigger .selected-dots { + display: flex; + gap: 0.25rem; + align-items: center; + } + .project-dropdown-menu { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 220px; + background: #1e293b; + border: 1px solid #334155; + border-radius: 0.5rem; + box-shadow: 0 10px 25px rgba(0,0,0,0.4); + z-index: 100; + overflow: hidden; + } + .project-dropdown.open .project-dropdown-menu { + display: block; + } + .project-dropdown-actions { + display: flex; + border-bottom: 1px solid #334155; + } + .project-dropdown-actions button { + flex: 1; + padding: 0.5rem; + background: none; + border: none; + color: #94a3b8; + font-size: 0.75rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; + } + .project-dropdown-actions button:hover { + background: #334155; + color: #e2e8f0; + } + .project-dropdown-actions button:first-child { + border-right: 1px solid #334155; + } + .project-dropdown-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + transition: background 0.15s; + font-size: 0.875rem; + } + .project-dropdown-item:hover { + background: #334155; + } + .project-dropdown-item .checkbox { + width: 1rem; + height: 1rem; + border: 1.5px solid #475569; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; + } + .project-dropdown-item.selected .checkbox { + background: #3b82f6; + border-color: #3b82f6; + } + .project-dropdown-item.selected .checkbox::after { + content: ''; + width: 0.35rem; + height: 0.6rem; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg) translateY(-1px); } .board { padding: 2rem; @@ -516,7 +626,20 @@
- +
+
+ + All Projects + +
+
+
+ + +
+
+
+
@@ -660,7 +783,7 @@