this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: misc issues with filter + app-sidebar

we moved filter state into it's own context so it can be changed and
accessed anywhere.
filter-context now handles it's own parsing, and changes are always
propagated through filter-text as the state. newText gets updated to be
filter text when in filter mode, and the side bar reacts to and changes
the filter text as well.

+369 -107
+75 -67
mast-react-vite/src/App.tsx
··· 4 4 import * as commandParser from "@/lib/command_js.js"; 5 5 import { ActionParser } from "@/components/ui/action-parser"; 6 6 import { useSelection } from "@/contexts/selection-context"; 7 + import { useFilter } from "@/contexts/filter-context"; 7 8 import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; 8 9 import { AppSidebar } from "@/components/ui/app-sidebar"; 9 10 import { Badge } from "@/components/ui/badge"; ··· 19 20 function App({ ctx, syncWorker, dbname, roomId }) { 20 21 const [newText, setNewText] = useState(""); 21 22 const { selectedItems, clearSelection, getSelectionString } = useSelection(); 23 + const { 24 + filterText, 25 + setFilterText, 26 + filterState, 27 + getWhereClause, 28 + clearFilters 29 + } = useFilter(); 22 30 const [currentAction, setCurrentAction] = useState("add"); 23 - const [filterContext, setFilterContext] = useState({}); 24 - 25 - const newConditions = []; 26 - const newParams = []; 27 - 28 - if ( 29 - filterContext.filterDescription && 30 - filterContext.filterDescription.length > 0 31 - ) { 32 - newConditions.push("description LIKE ?"); 33 - newParams.push(`%${filterContext.filterDescription}%`); 34 - } 35 - if (filterContext.filterProject && filterContext.filterProject.length > 0) { 36 - newConditions.push("project = ?"); 37 - newParams.push(filterContext.filterProject); 38 - } 39 - if (filterContext.filterTags && filterContext.filterTags.length > 0) { 40 - newConditions.push(`EXISTS ( 41 - SELECT 1 FROM json_each(tags) 42 - WHERE json_each.value IN (${filterContext.filterTags.map(() => "?").join(",")}) 43 - )`); 44 - newParams.push(...filterContext.filterTags); 45 - } 31 + 32 + // Get current conditions and parameters for SQL query 33 + const { conditions: newConditions, params: newParams } = getWhereClause(); 46 34 47 35 const whereClause = [ 48 36 newConditions.length > 0 ? `(${newConditions.join(" AND ")})` : null, ··· 60 48 newParams, 61 49 ).data; 62 50 51 + // Keep newText in sync with filterText when in filter mode 63 52 useEffect(() => { 64 - if ( 65 - currentAction !== "filter" && 66 - newText.trim().length !== 0 && 67 - filterContext.filterTemp === true 68 - ) { 69 - console.log("clear filter!"); 70 - setFilterContext({}); 53 + // Only update if we're in filter mode 54 + if (currentAction === "filter") { 55 + setNewText(filterText); 71 56 } 72 - }, [currentAction]); 57 + }, [filterText, currentAction]); 58 + 59 + // Listen for filter update events from sidebar - primarily for debugging 60 + useEffect(() => { 61 + const handleFilterUpdate = (event) => { 62 + // Log the event for debugging 63 + console.log('Filter updated via sidebar:', event.detail); 64 + }; 65 + 66 + // Add event listener 67 + document.addEventListener('filter:update', handleFilterUpdate); 68 + 69 + // Clean up event listener 70 + return () => { 71 + document.removeEventListener('filter:update', handleFilterUpdate); 72 + }; 73 + }, []); 73 74 74 75 const parsedCommand = useMemo(() => { 75 76 try { ··· 148 149 } 149 150 }, [parsedCommand, todos, selectedItems]); 150 151 152 + // When parsed command changes to a filter action, update the filter text 151 153 useEffect(() => { 152 154 if (parsedCommand.action === "filter") { 153 - setFilterContext({ 154 - filterTags: parsedCommand.tags, 155 - filterProject: parsedCommand.project, 156 - filterDescription: parsedCommand.description, 157 - filterTemp: true, 158 - }); 159 - } else if ( 160 - currentAction !== "filter" && 161 - newText.trim().length === 0 && 162 - filterContext.filterTemp === true 163 - ) { 164 - setFilterContext({}); 155 + // Set the filter text in the context when a filter command is parsed 156 + setFilterText(newText); 165 157 } 166 - }, [parsedCommand, currentAction, newText]); 158 + }, [parsedCommand, newText, setFilterText]); 167 159 168 160 const handleActionChange = (action: string) => { 161 + // If switching to filter action, use the current filter text from context 162 + if (action === "filter") { 163 + if (filterText) { 164 + setNewText(filterText); 165 + } 166 + } else if (currentAction === "filter" && action !== "filter") { 167 + // If switching away from filter, clear the text input but keep the filter active 168 + setNewText(""); 169 + } 170 + 169 171 setCurrentAction(action); 170 172 }; 171 173 172 - const handleNewTextChange = (newText: string) => { 173 - setNewText(newText); 174 + const handleNewTextChange = (text: string) => { 175 + // Update the local state 176 + setNewText(text); 177 + 178 + // If the current action is filter, also update the filter text in the context 179 + // This enables real-time filtering as the user types 180 + if (currentAction === "filter") { 181 + // Only update the filter text if it's different to avoid circular updates 182 + if (text !== filterText) { 183 + setFilterText(text); 184 + } 185 + } 174 186 }; 175 187 176 188 // Callback to mark tasks as done when dragged past threshold ··· 219 231 } 220 232 if (e.keyCode === 8) { 221 233 if (newText.trim().length === 0) { 222 - setFilterContext({}); 234 + clearFilters(); 223 235 } 224 236 } 225 237 }; ··· 327 339 } 328 340 break; 329 341 case "filter": 330 - setFilterContext({ 331 - // I think reconstruct is broken 332 - // filterText: parsed.reconstruct(), 333 - filterTags: parsed.tags, 334 - filterProject: parsed.project, 335 - filterDescription: parsed.description, 336 - // Don't clear the filter context on actionChange 337 - filterTemp: false, 338 - }); 342 + // Set the filter text in the context - this will trigger parsing in the filter context 343 + // Only update if different to avoid unnecessary re-renders 344 + if (newText !== filterText) { 345 + setFilterText(newText); 346 + } 339 347 break; 340 348 } 341 - setNewText(""); 349 + 350 + // Only clear the text input for non-filter actions 351 + if (parsed.action !== "filter") { 352 + setNewText(""); 353 + } 342 354 343 355 // Explicitly tell the worker to sync changes after modifying the database 344 356 if (parsed.action !== "filter") { // Only sync for data-changing operations ··· 364 376 <> 365 377 <SidebarProvider defaultOpen={false} className="h-screen"> 366 378 <div className="hidden md:flex flex-col w-full h-svh"> 367 - <AppSidebar ctx={ctx} 368 - filterContext={filterContext} 369 - setFilterContext={setFilterContext} /> 379 + <AppSidebar ctx={ctx} /> 370 380 371 381 <div className="bg-muted h-14 w-full absolute top-0 " /> 372 382 <section className="flex-1 container py-12 h-[calc(100vh-theme(spacing.4))] overflow-hidden relative"> ··· 375 385 {selectedItems.size > 0 && ( 376 386 <Badge variant="default"> {selectedItems.size} selected </Badge> 377 387 )} 378 - {Object.keys(filterContext).length > 0 && ( 388 + {(filterState.filterTags?.length > 0 || filterState.filterProject || filterState.filterDescription) && ( 379 389 <Badge variant="secondary" className="cursor-pointer"> 380 390 {todos.length} items found 381 391 <button 382 - onClick={() => setFilterContext({})} 392 + onClick={() => clearFilters()} 383 393 className="hover:bg-muted rounded-full p-1" 384 394 > 385 395 ··· 407 417 408 418 {/* Mobile view layout - shown only on mobile */} 409 419 <div className="md:hidden flex left-0 w-full h-full flex flex-col items-center pb-2"> 410 - <AppSidebar ctx={ctx} 411 - filterContext={filterContext} 412 - setFilterContext={setFilterContext} /> 420 + <AppSidebar ctx={ctx} /> 413 421 414 422 <div className="bg-muted h-14 w-full absolute top-0 " /> 415 423 <section className="flex-1 w-full py-12 h-[calc(100vh-theme(spacing.4))] overflow-hidden relative"> ··· 418 426 {selectedItems.size > 0 && ( 419 427 <Badge variant="default"> {selectedItems.size} selected </Badge> 420 428 )} 421 - {Object.keys(filterContext).length > 0 && ( 429 + {(filterState.filterTags?.length > 0 || filterState.filterProject || filterState.filterDescription) && ( 422 430 <Badge variant="secondary" className="cursor-pointer"> 423 431 {todos.length} items found 424 432 <button 425 - onClick={() => setFilterContext({})} 433 + onClick={() => clearFilters()} 426 434 className="hover:bg-muted rounded-full p-1" 427 435 > 428 436
+67 -37
mast-react-vite/src/components/ui/app-sidebar.tsx
··· 11 11 } from "@/components/ui/sidebar"; 12 12 import { useState, useEffect } from "react"; 13 13 import { useQuery } from "@vlcn.io/react"; 14 + import { useFilter } from "@/contexts/filter-context"; 14 15 import { Project } from "@/components/ui/project"; 15 16 import { Tag } from "@/components/ui/tag"; 17 + 18 + // Command parser result type based on usage in App.tsx 19 + type ParsedCommand = { 20 + action?: string; 21 + description?: string; 22 + project?: string; 23 + tags?: string[]; 24 + selection?: any[]; 25 + }; 16 26 17 27 // Helper function to load all visited rooms 18 28 function loadAllRooms(): Set<string> { ··· 35 45 window.location.hash = params.toString(); 36 46 } 37 47 38 - export function AppSidebar({ctx, filterContext, setFilterContext}) { 48 + export function AppSidebar({ctx}) { 49 + const { filterState, toggleProject, toggleTag } = useFilter(); 39 50 // Load the visited rooms 40 51 const [visitedRooms, setVisitedRooms] = useState<string[]>([]); 41 52 const [currentRoom, setCurrentRoom] = useState<string>(""); ··· 72 83 `SELECT DISTINCT project FROM active_todos WHERE project != '' ORDER BY project` 73 84 ).data || []; 74 85 75 - const handleProjectClick = (project) => { 76 - // Toggle project selection - if already selected, remove it 77 - if (filterContext.filterProject === project) { 78 - // Create a new object without the filterProject property 79 - const { filterProject, ...restFilterContext } = filterContext; 80 - setFilterContext(restFilterContext); 81 - } else { 82 - // Set the new project 83 - setFilterContext({ 84 - ...filterContext, 85 - filterProject: project, 86 - filterTemp: false, 87 - }); 88 - } 86 + const handleProjectClick = (project, e) => { 87 + // Prevent default behavior and stop propagation to keep sidebar open 88 + if (e) { 89 + e.preventDefault(); 90 + e.stopPropagation(); 91 + } 92 + 93 + toggleProject(project); 94 + 95 + const event = new CustomEvent('filter:update', { 96 + bubbles: true, 97 + detail: { action: 'project', project } 98 + }); 99 + document.dispatchEvent(event); 89 100 }; 90 101 91 102 const tags = useQuery( ··· 93 104 `SELECT DISTINCT value as tag FROM active_todos, json_each(active_todos.tags) ORDER BY tag` 94 105 ).data || []; 95 106 96 - const handleTagClick = (tag) => { 97 - const currentTags = filterContext.filterTags || []; 98 - const newTags = currentTags.includes(tag) 99 - ? currentTags.filter(t => t !== tag) 100 - : [...currentTags, tag]; 101 - 102 - setFilterContext({ 103 - ...filterContext, 104 - filterTags: newTags, 105 - filterTemp: false, 107 + const handleTagClick = (tag, e) => { 108 + // Prevent default behavior and stop propagation to keep sidebar open 109 + if (e) { 110 + e.preventDefault(); 111 + e.stopPropagation(); 112 + } 113 + 114 + // Simply toggle the tag - the filter context will handle parsing and updating state 115 + toggleTag(tag); 116 + 117 + // Dispatch event to notify other components of the filter update 118 + const event = new CustomEvent('filter:update', { 119 + bubbles: true, 120 + detail: { action: 'tag', tag } 106 121 }); 122 + document.dispatchEvent(event); 123 + }; 124 + 125 + // Create a function to stop click propagation to prevent sidebar from closing 126 + const stopPropagation = (e) => { 127 + e.stopPropagation(); 107 128 }; 108 129 109 130 return ( 110 131 <Sidebar> 132 + {/* This div ensures all clicks inside the sidebar are captured and don't propagate up */} 133 + <div 134 + onMouseDown={stopPropagation} 135 + onClick={stopPropagation} 136 + style={{ width: '100%', height: '100%' }} 137 + > 111 138 <SidebarHeader /> 112 139 <SidebarContent> 113 140 {visitedRooms.length > 0 && ( 114 - <SidebarGroup> 141 + <SidebarGroup onMouseDown={stopPropagation}> 115 142 <SidebarGroupLabel>Rooms</SidebarGroupLabel> 116 - <SidebarMenu> 143 + <SidebarMenu onMouseDown={stopPropagation}> 117 144 <div className="flex flex-wrap gap-1.5 p-2 max-h-40 overflow-y-auto"> 118 145 {visitedRooms.map((roomId) => ( 119 146 <SidebarMenuItem key={roomId}> ··· 129 156 </SidebarMenu> 130 157 </SidebarGroup> 131 158 )} 132 - <SidebarGroup> 159 + <SidebarGroup onMouseDown={stopPropagation}> 133 160 <SidebarGroupLabel>Projects</SidebarGroupLabel> 134 161 <SidebarMenu> 135 - <div className="flex flex-wrap gap-1.5 p-2 max-h-40 overflow-y-auto"> 162 + <div className="flex flex-wrap gap-1.5 p-2 max-h-40 overflow-y-auto" onMouseDown={(e) => e.stopPropagation()}> 136 163 {projects.map((item) => ( 137 164 <SidebarMenuItem key={item.project}> 138 165 <SidebarMenuButton 139 - isActive={filterContext.filterProject === item.project} 140 - onClick={() => handleProjectClick(item.project)} 166 + isActive={filterState.filterProject === item.project} 167 + onClick={(e) => handleProjectClick(item.project, e)} 168 + onMouseDown={(e) => e.stopPropagation()} 141 169 > 142 170 <Project name={item.project} /> 143 171 </SidebarMenuButton> ··· 151 179 </div> 152 180 </SidebarMenu> 153 181 </SidebarGroup> 154 - <SidebarGroup> 182 + <SidebarGroup onMouseDown={stopPropagation}> 155 183 <SidebarGroupLabel>Tags</SidebarGroupLabel> 156 184 <SidebarMenu> 157 - <div className="flex flex-wrap gap-1.5 p-2 max-h-40 overflow-y-auto"> 185 + <div className="flex flex-wrap gap-1.5 p-2 max-h-40 overflow-y-auto" onMouseDown={(e) => e.stopPropagation()}> 158 186 {tags.map((item) => ( 159 187 <SidebarMenuItem key={item.tag}> 160 188 <SidebarMenuButton 161 - isActive={filterContext.filterTags && filterContext.filterTags.includes(item.tag)} 162 - onClick={() => handleTagClick(item.tag)} 189 + isActive={filterState.filterTags && filterState.filterTags.includes(item.tag)} 190 + onClick={(e) => handleTagClick(item.tag, e)} 191 + onMouseDown={(e) => e.stopPropagation()} 163 192 > 164 193 <Tag tag={item.tag} /> 165 194 </SidebarMenuButton> ··· 173 202 </div> 174 203 </SidebarMenu> 175 204 </SidebarGroup> 176 - <SidebarGroup /> 205 + <SidebarGroup onMouseDown={stopPropagation} /> 177 206 </SidebarContent> 178 207 <SidebarFooter /> 208 + </div> 179 209 </Sidebar> 180 210 ); 181 - } 211 + }
+221
mast-react-vite/src/contexts/filter-context.tsx
··· 1 + import { createContext, useContext, useState, useEffect } from "react"; 2 + import * as commandParser from "@/lib/command_js.js"; 3 + 4 + // Command parser result type based on usage in App.tsx 5 + type ParsedCommand = { 6 + action?: string; 7 + description?: string; 8 + project?: string; 9 + tags?: string[]; 10 + selection?: any[]; 11 + }; 12 + 13 + type FilterContextType = { 14 + // Core state - filter text is the single source of truth 15 + filterText: string; 16 + setFilterText: (text: string) => void; 17 + 18 + // Parsed filter state (derived from filterText) 19 + filterState: FilterState; 20 + 21 + // Utility functions 22 + clearFilters: () => void; 23 + toggleProject: (project: string) => void; 24 + toggleTag: (tag: string) => void; 25 + getWhereClause: () => { conditions: string[]; params: any[] }; 26 + }; 27 + 28 + type FilterState = { 29 + filterDescription?: string; 30 + filterProject?: string; 31 + filterTags?: string[]; 32 + }; 33 + 34 + // Create a default context value 35 + const defaultContextValue: FilterContextType = { 36 + filterText: "", 37 + setFilterText: () => {}, 38 + filterState: {}, 39 + clearFilters: () => {}, 40 + toggleProject: () => {}, 41 + toggleTag: () => {}, 42 + getWhereClause: () => ({ conditions: [], params: [] }), 43 + }; 44 + 45 + const FilterContext = createContext<FilterContextType>(defaultContextValue); 46 + 47 + export function FilterProvider({ children }: { children: React.ReactNode }) { 48 + // The filter text is the primary state 49 + const [filterText, setFilterText] = useState<string>(""); 50 + // The parsed filter state is derived from the filter text 51 + const [filterState, setFilterState] = useState<FilterState>({}); 52 + 53 + // Parse the filter text whenever it changes 54 + useEffect(() => { 55 + if (filterText.trim() === "") { 56 + setFilterState({}); 57 + return; 58 + } 59 + 60 + try { 61 + // Parse the filter text using the command parser 62 + // We prepend "filter" to ensure it's parsed as a filter command 63 + const parsed = commandParser.parse("filter " + filterText); 64 + 65 + if (parsed.action === "filter") { 66 + const newState: FilterState = {}; 67 + 68 + if (parsed.description) { 69 + newState.filterDescription = parsed.description; 70 + } 71 + 72 + if (parsed.project) { 73 + newState.filterProject = parsed.project; 74 + } 75 + 76 + if (parsed.tags && parsed.tags.length > 0) { 77 + newState.filterTags = parsed.tags; 78 + } 79 + 80 + setFilterState(newState); 81 + } 82 + } catch (error) { 83 + console.error("Error parsing filter text:", error); 84 + // If parsing fails, at least try to extract some information 85 + // This is a simple fallback that tries to identify tags and projects 86 + const fallbackState: FilterState = {}; 87 + 88 + // Look for both project: pattern and +project pattern (for projects) 89 + let projectMatch = filterText.match(/project:([^\s]+)/); 90 + if (!projectMatch) { 91 + projectMatch = filterText.match(/\+([^\s]+)/); 92 + } 93 + if (projectMatch) { 94 + fallbackState.filterProject = projectMatch[1]; 95 + } 96 + 97 + // Look for #tag pattern 98 + const tags: string[] = []; 99 + const tagMatches = filterText.match(/\#([^\s]+)/g); 100 + if (tagMatches) { 101 + tagMatches.forEach(match => { 102 + tags.push(match.substring(1)); 103 + }); 104 + if (tags.length > 0) { 105 + fallbackState.filterTags = tags; 106 + } 107 + } 108 + 109 + // Anything else is treated as description 110 + const remainingText = filterText 111 + .replace(/\#[^\s]+/g, "") // Remove tags 112 + .replace(/\+[^\s]+/g, "") // Remove projects with + prefix 113 + .replace(/project:[^\s]+/g, "") // Remove projects with project: prefix 114 + .trim(); 115 + 116 + if (remainingText) { 117 + fallbackState.filterDescription = remainingText; 118 + } 119 + 120 + setFilterState(fallbackState); 121 + } 122 + }, [filterText]); 123 + 124 + // Clear all filters 125 + const clearFilters = () => { 126 + setFilterText(""); 127 + setFilterState({}); 128 + }; 129 + 130 + // Toggle project in filter 131 + const toggleProject = (project: string) => { 132 + // Use simple string operations rather than complex regex 133 + if (filterState.filterProject === project) { 134 + // Just clear the filter text - simpler than trying to extract parts 135 + // This handles the case where we want to remove the current project 136 + const parts = filterText.split(' '); 137 + // Filter out parts that match this project specification 138 + const filtered = parts.filter(part => 139 + part !== `+${project}` && 140 + part !== `project:${project}` 141 + ); 142 + setFilterText(filtered.join(' ')); 143 + } else { 144 + // First remove any existing project specifications 145 + const parts = filterText.split(' '); 146 + const filtered = parts.filter(part => 147 + !part.startsWith('+') && 148 + !part.startsWith('project:') 149 + ); 150 + // Add the new project 151 + filtered.push(`+${project}`); 152 + setFilterText(filtered.join(' ')); 153 + } 154 + }; 155 + 156 + // Toggle tag in filter 157 + const toggleTag = (tag: string) => { 158 + // Use simple string operations rather than complex regex 159 + if (filterState.filterTags && filterState.filterTags.includes(tag)) { 160 + // Remove the tag from the filter text 161 + const parts = filterText.split(' '); 162 + const filtered = parts.filter(part => part !== `#${tag}`); 163 + setFilterText(filtered.join(' ')); 164 + } else { 165 + // Add the tag to the filter text 166 + const parts = filterText.split(' '); 167 + parts.push(`#${tag}`); 168 + setFilterText(parts.join(' ')); 169 + } 170 + }; 171 + 172 + // Function to generate WHERE clause for SQL queries 173 + const getWhereClause = () => { 174 + const conditions: string[] = []; 175 + const params: any[] = []; 176 + 177 + if ( 178 + filterState.filterDescription && 179 + filterState.filterDescription.length > 0 180 + ) { 181 + conditions.push("description LIKE ?"); 182 + params.push(`%${filterState.filterDescription}%`); 183 + } 184 + 185 + if (filterState.filterProject && filterState.filterProject.length > 0) { 186 + conditions.push("project = ?"); 187 + params.push(filterState.filterProject); 188 + } 189 + 190 + if (filterState.filterTags && filterState.filterTags.length > 0) { 191 + conditions.push(`EXISTS ( 192 + SELECT 1 FROM json_each(tags) 193 + WHERE json_each.value IN (${filterState.filterTags.map(() => "?").join(",")}) 194 + )`); 195 + params.push(...filterState.filterTags); 196 + } 197 + 198 + return { conditions, params }; 199 + }; 200 + 201 + return ( 202 + <FilterContext.Provider 203 + value={{ 204 + filterText, 205 + setFilterText, 206 + filterState, 207 + clearFilters, 208 + toggleProject, 209 + toggleTag, 210 + getWhereClause, 211 + }} 212 + > 213 + {children} 214 + </FilterContext.Provider> 215 + ); 216 + } 217 + 218 + export const useFilter = (): FilterContextType => { 219 + const context = useContext(FilterContext); 220 + return context; 221 + };
+6 -3
mast-react-vite/src/main.tsx
··· 7 7 import wasmUrl from "@vlcn.io/crsqlite-wasm/crsqlite.wasm?url"; 8 8 import tblrx from "@vlcn.io/rx-tbl"; 9 9 import { SelectionProvider } from "@/contexts/selection-context"; 10 + import { FilterProvider } from "@/contexts/filter-context"; 10 11 import { useCustomSync } from "@/hooks/use-sync"; 11 12 import { SyncStatus } from "@/components/ui/sync-status"; 12 13 ··· 528 529 </Helmet> 529 530 <main className="h-screen flex flex-col bg-background text-gray-200"> 530 531 <SelectionProvider> 531 - {/* Use the custom sync component with the database name and room ID */} 532 - <CustomSyncComponent dbname={ctx.dbname} roomId={ctx.roomId} /> 533 - <App ctx={ctx} syncWorker={syncWorker} dbname={ctx.dbname} roomId={ctx.roomId} /> 532 + <FilterProvider> 533 + {/* Use the custom sync component with the database name and room ID */} 534 + <CustomSyncComponent dbname={ctx.dbname} roomId={ctx.roomId} /> 535 + <App ctx={ctx} syncWorker={syncWorker} dbname={ctx.dbname} roomId={ctx.roomId} /> 536 + </FilterProvider> 534 537 </SelectionProvider> 535 538 </main> 536 539 </>,