this repo has no description
2
fork

Configure Feed

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

feat: swipe to mark done

+198 -74
+40 -38
mast-react-vite/src/App.tsx
··· 53 53 .filter(Boolean) 54 54 .join(" OR "); 55 55 56 - console.log("updating todos"); 56 + console.log("updating todos: ", whereClause, "newParams: ", newParams); 57 57 const todos = useQuery( 58 58 ctx, 59 59 `SELECT * FROM active_todos ${whereClause ? "WHERE " + whereClause : ""}`, ··· 171 171 172 172 const handleNewTextChange = (newText: string) => { 173 173 setNewText(newText); 174 + }; 175 + 176 + // Callback to mark tasks as done when dragged past threshold 177 + const handleMarkDone = (selectedIds: number[]) => { 178 + if (selectedIds.length > 0) { 179 + const selection = [{ 180 + type: "id", 181 + ids: selectedIds 182 + }]; 183 + 184 + const { conditions, params } = parseSelection({ 185 + selection, 186 + conditions: [], 187 + params: [] 188 + }); 189 + 190 + if (conditions.length > 0) { 191 + const sqlQuery = ` 192 + UPDATE todos 193 + SET completed = 1 194 + WHERE ${conditions.join(" OR ")} 195 + `; 196 + 197 + console.log(sqlQuery, params) 198 + 199 + ctx.db.exec(sqlQuery, params); 200 + clearSelection(); 201 + 202 + // Trigger sync with small delay to ensure changes are committed 203 + setTimeout(() => { 204 + syncWorker.postMessage({ 205 + type: 'SYNC_CHANGES', 206 + dbname 207 + }); 208 + }, 100); 209 + } 210 + } 174 211 }; 175 212 176 213 // Use previewTodos if available, otherwise use regular todos ··· 181 218 executeCommand(); 182 219 } 183 220 if (e.keyCode === 8) { 184 - console.log("backspace"); 185 221 if (newText.trim().length === 0) { 186 222 setFilterContext({}); 187 223 } ··· 212 248 const executeCommand = () => { 213 249 console.log(getSelectionString() + " " + currentAction + " " + newText); 214 250 try { 215 - // TODO selection context should go here 216 251 const parsed = commandParser.parse( 217 252 getSelectionString() + " " + currentAction + " " + newText, 218 253 ); ··· 325 360 } 326 361 }; 327 362 328 - //const parseTodos = (e) => { 329 - // // On enter execute the command 330 - // if (e.key === "Enter") { 331 - // // TODO We should pass selectionContext here 332 - // executeCommand(currentAction + " " + e.target.value); 333 - // } 334 - // // React to key presses for selection 335 - // else if (e.target.value.trim() !== "") { 336 - // try { 337 - // // TODO: 338 - // // This allows editing selectionContext from the newText field 339 - // // But we've moved selectionContext into it's own place 340 - // // So we shouldn't have to do this here 341 - // const parsed = commandParser.parse(currentAction + " " + e.target.value); 342 - // if (parsed.filters && parsed.filters.length > 0) { 343 - // const idFilters = parsed.filters.filter(f => f.type === "id"); 344 - // // Create new selection state 345 - // const newSelection = {}; 346 - // idFilters.forEach(filter => { 347 - // filter.ids.forEach(id => { 348 - // newSelection[id - 1] = true; 349 - // }) 350 - // }); 351 - 352 - // } 353 - // } catch (error) { 354 - // console.log("Unable to parse field onUpdate") 355 - // return; 356 - // } 357 - // } else { 358 - // } 359 - //} 360 - 361 363 return ( 362 364 <> 363 365 <SidebarProvider defaultOpen={false} className="h-screen"> ··· 398 400 /> 399 401 <div className="h-2" /> 400 402 <div className="flex-1 w-full"> 401 - <DataTable data={displayTodos} /> 403 + <DataTable data={displayTodos} onMarkDone={handleMarkDone} /> 402 404 </div> 403 405 </section> 404 406 </div> ··· 440 442 /> 441 443 <div className="h-2" /> 442 444 <div className="flex-1 w-full h-full"> 443 - <DataTable data={displayTodos} /> 445 + <DataTable data={displayTodos} onMarkDone={handleMarkDone} /> 444 446 </div> 445 447 </section> 446 448 </div>
+4 -3
mast-react-vite/src/components/ui/data-table.tsx
··· 12 12 13 13 interface DataTableProps<TData> { 14 14 data: TData[]; 15 + onMarkDone?: (selectedIds: number[]) => void; 15 16 } 16 17 17 - export function DataTable<TData>({ data }: DataTableProps<TData>) { 18 + export function DataTable<TData>({ data, onMarkDone }: DataTableProps<TData>) { 18 19 return ( 19 20 <> 20 21 <div className="hidden md:flex flex-col w-full"> 21 22 <ScrollArea className="h-[calc(100vh-12rem)] rounded-md border"> 22 23 {data.length ? ( 23 - data.map((item, index) => <Task key={index} data={item} />) 24 + data.map((item, index) => <Task key={index} data={item} onMarkDone={onMarkDone} />) 24 25 ) : ( 25 26 <div className="text-center py-4 text-muted-foreground"> 26 27 No results. ··· 33 34 <div className="md:hidden"> 34 35 <ScrollArea className="min-h-[120%] h-[calc(100vh)] border"> 35 36 {data.length ? ( 36 - data.map((item, index) => <Task key={index} data={item} />) 37 + data.map((item, index) => <Task key={index} data={item} onMarkDone={onMarkDone} />) 37 38 ) : ( 38 39 <div className="text-center py-4 text-muted-foreground"> 39 40 No results.
+131 -32
mast-react-vite/src/components/ui/task.tsx
··· 5 5 import { TagList } from "@/components/ui/tag"; 6 6 import { TaskDetailView } from "@/components/ui/task-detail-view"; 7 7 import { useIsMobile } from "@/hooks/use-mobile"; 8 + import { motion, animate, AnimatePresence } from "motion/react"; 8 9 9 10 interface TaskProps { 10 11 selected?: boolean; 11 12 onSelect?: () => void; 12 13 data: any; 13 14 index?: number; 15 + onMarkDone?: (selectedIds: number[]) => void; // Callback to mark tasks as done 14 16 } 15 17 16 - export function Task({ selected, onSelect, data }: TaskProps) { 17 - const { isSelected, toggleSelection } = useSelection(); 18 + export function Task({ selected, onSelect, data, onMarkDone }: TaskProps) { 19 + // Track whether this task should be visible or exit-animating 20 + const [isVisible, setIsVisible] = React.useState(true); 21 + const { isSelected, toggleSelection, selectItem, sharedDragX, selectedItems, clearSelection} = useSelection(); 18 22 const tagList = JSON.parse(data.tags); 19 23 const [showDetailView, setShowDetailView] = React.useState(false); 20 24 21 - const [clickCount, setClickCount] = React.useState(0); 22 - const [clickTimer, setClickTimer] = React.useState<NodeJS.Timeout | null>(null); 25 + // Use a ref instead of state to track dragging without re-renders 26 + const dragInfo = React.useRef({ isDragging: false, dragDistance: 0 }); 27 + 28 + const snapPoint = 140; // Distance in pixels where the card snaps to action 29 + const dragThreshold = 100; // Minimum drag distance to trigger the action 30 + 31 + // Set sharedDragX to snapPoint for tasks in preview-done state (status 3) 32 + React.useEffect(() => { 33 + if (isSelected(data.working_id)) { 34 + if (data.completed === 3 ) { 35 + // For preview-done tasks, position at snapPoint to show the done indicator 36 + sharedDragX.set(snapPoint); 37 + } else { 38 + sharedDragX.set(0) 39 + } 40 + } 41 + }, [data.completed, isSelected, data.working_id, snapPoint]); 42 + 43 + // Only selected tasks use the shared motion value 44 + const xPosition = isSelected(data.working_id) ? sharedDragX : 0 45 + 23 46 24 47 const hasPreview = data.previewMode && data.preview; 25 48 ··· 40 63 switch (status) { 41 64 case 2: // preview-add 42 65 return "bg-yellow-100 border-yellow-600 text-yellow-700 "; 43 - case 3: // preview-done 44 - return "bg-green-100 text-green-700 border-green-600 opacity-50"; 45 66 default: 46 67 return isSelected(data.working_id) 47 68 ? "bg-muted border-primary" 48 69 : "bg-card"; 49 70 } 50 71 }; 51 - 52 - // Handle click/tap with double click detection 53 - const handleClick = (e: React.MouseEvent) => { 54 - e.preventDefault(); 72 + 73 + // Create resistance when dragging past the snap point 74 + const handleDrag = (event, info) => { 75 + // Track dragging state and distance in the ref 76 + dragInfo.current.isDragging = true; 77 + dragInfo.current.dragDistance = Math.abs(info.offset.x); 55 78 56 - setClickCount(prev => prev + 1); 79 + // As soon as dragging starts, select this task (if not already selected) 80 + // This ensures it will now use the shared motion value 81 + if (!isSelected(data.working_id)) { 82 + selectItem(data.working_id); 83 + } 84 + 85 + // Get the current starting position (will be snapPoint in done mode, 0 otherwise) 86 + const startPosition = data.completed === 3 ? snapPoint : 0; 87 + const dragDistance = info.offset.x; 88 + 89 + // Calculate position with resistance past snap point, relative to starting position 90 + let newPosition; 57 91 58 - // Clear any existing timer 59 - if (clickTimer) { 60 - clearTimeout(clickTimer); 92 + if (dragDistance <= snapPoint) { 93 + // Before additional snap point - normal drag behavior 94 + newPosition = startPosition + dragDistance; 95 + } else { 96 + // Past snap point - apply increasing resistance 97 + const overDrag = dragDistance - snapPoint; 98 + const resistanceFactor = 0.15; 99 + newPosition = startPosition + snapPoint + (Math.log(1 + overDrag * resistanceFactor) / resistanceFactor); 61 100 } 62 101 63 - // If this is the first click/tap, set up a timer to reset the counter 64 - if (clickCount === 0) { 65 - const timer = setTimeout(() => { 66 - if (clickCount === 0) { 67 - toggleSelection(data.working_id); 68 - } 69 - setClickCount(0); 70 - }, 300); // 300ms is a common double-click interval 102 + // Always use the shared motion value (since the task is now selected) 103 + sharedDragX.set(newPosition); 104 + }; 105 + 106 + // Handle drag end and determine if tasks should be marked as done 107 + const handleDragEnd = (event, info) => { 108 + dragInfo.current.isDragging = false; 109 + selectItem(data.working_id); 110 + 111 + const offset = info.offset.x; 112 + const velocity = info.velocity.x; 113 + 114 + const pastThreshold = offset > dragThreshold || velocity > 500; 115 + if (pastThreshold && onMarkDone) { 116 + const selectedIds = Array.from(selectedItems).map(id => Number(id)); 71 117 72 - setClickTimer(timer); 73 - } 74 - else if (clickCount === 1) { 75 - setShowDetailView(true); 76 - setClickCount(0); 118 + setIsVisible(false); 119 + // Reset visibility after task is marked done 120 + // This ensures new tasks with the same ID will be visible 121 + setTimeout(() => { 122 + onMarkDone(selectedIds); 123 + setIsVisible(true); 124 + }, 301); 125 + } else { 126 + // If we're in "done mode" (data.completed === 3), reset to snapPoint 127 + // Otherwise reset to 0 as usual 128 + const targetPosition = data.completed === 3 ? snapPoint : 0; 129 + animate(sharedDragX, targetPosition, { type: "spring", stiffness: 400, damping: 40 }); 77 130 } 78 131 }; 79 - 132 + 80 133 return ( 81 134 <> 82 - <div 83 - className={`p-4 ${getStateStyles(data.completed)} md:hover:bg-muted/50 transition-colors`} 84 - onClick={handleClick} 85 - > 135 + <AnimatePresence initial={false}> 136 + {isVisible && ( 137 + <div className="relative"> 138 + {/* "Done" div that appears underneath the task when dragging */} 139 + <div className="bg-green-600 text-white p-4 flex items-center gap-2 absolute top-0 left-0 w-full h-full"> 140 + <div className="w-5 h-5 bg-white rounded-full flex items-center justify-center"> 141 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="w-3 h-3 text-green-600"> 142 + <polyline points="20 6 9 17 4 12"></polyline> 143 + </svg> 144 + </div> 145 + <span className="font-medium">Done</span> 146 + </div> 147 + 148 + <motion.div 149 + className={`p-4 ${getStateStyles(data.completed)} md:hover:bg-neutral-900 relative z-10 bg-card`} 150 + onClick={(e) => { 151 + // Check if this is a genuine click or the result of a drag operation 152 + // A real click will have minimal drag distance 153 + const wasDragging = dragInfo.current.isDragging; 154 + const dragDistance = dragInfo.current.dragDistance; 155 + 156 + // Reset the drag info for next time 157 + dragInfo.current = { isDragging: false, dragDistance: 0 }; 158 + 159 + // Only toggle if it wasn't part of a drag 160 + if (!wasDragging || dragDistance < 5) { // 5px is a good threshold for distinguishing clicks 161 + toggleSelection(data.working_id); 162 + } 163 + }} 164 + onDoubleClick={() => setShowDetailView(true)} 165 + drag="x" 166 + style={{ x: xPosition }} 167 + dragConstraints={{ left: 0, right: 0 }} 168 + onDrag={handleDrag} 169 + exit={{ 170 + x: "100vw", 171 + transition: { 172 + duration: 0.3, 173 + ease: "easeOut" 174 + } 175 + }} 176 + onDragEnd={handleDragEnd} 177 + dragElastic={0.3} 178 + dragDirectionLock 179 + dragMomentum={false} 180 + > 86 181 <div className="flex items-start gap-4"> 87 182 <div className="flex-1 min-w-0"> 88 183 <div className="flex items-center gap-1 relative ml-[-0.5rem]"> ··· 116 211 </div> 117 212 </div> 118 213 </div> 214 + </motion.div> 119 215 </div> 216 + )} 217 + </AnimatePresence> 218 + 120 219 <Separator /> 121 220 122 221 {/* Task Detail View Sheet */}
+23 -1
mast-react-vite/src/contexts/selection-context.tsx
··· 1 1 import { createContext, useContext, useState, useEffect } from "react"; 2 + import { useMotionValue } from "motion/react"; 2 3 3 4 type SelectionContextType = { 4 5 selectedItems: Set<number>; 5 6 toggleSelection: (id: number) => void; 7 + selectItem: (id: number) => void; 6 8 clearSelection: () => void; 7 9 isSelected: (id: number) => boolean; 8 10 getSelectionString: () => string; 11 + sharedDragX: any; // Shared motion value for synchronized dragging 9 12 }; 10 13 11 14 // Create a default context value to avoid the "dispatcher is null" error 12 15 const defaultContextValue: SelectionContextType = { 13 16 selectedItems: new Set<number>(), 14 17 toggleSelection: () => {}, 18 + selectItem: () => {}, 15 19 clearSelection: () => {}, 16 20 isSelected: () => false, 17 21 getSelectionString: () => "", 22 + sharedDragX: { get: () => 0, set: () => {} } 18 23 }; 19 24 20 25 const SelectionContext = createContext<SelectionContextType>(defaultContextValue); ··· 23 28 // Initialize with an empty Set and use useEffect for any side effects 24 29 const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set()); 25 30 const [isInitialized, setIsInitialized] = useState(false); 31 + 32 + // Shared motion value for synchronized task movement 33 + const sharedDragX = useMotionValue(0); 26 34 27 35 // Ensure the state is properly initialized when loaded from cache 28 36 useEffect(() => { ··· 43 51 }); 44 52 }; 45 53 54 + const selectItem = (id: number) => { 55 + if (!isInitialized) return; 56 + 57 + setSelectedItems((prev) => { 58 + const next = new Set(prev); 59 + if (!next.has(id)) { 60 + next.add(id); 61 + } 62 + return next; 63 + }); 64 + }; 65 + 46 66 const clearSelection = () => { 47 67 if (!isInitialized) return; 48 68 setSelectedItems(new Set()); ··· 53 73 const getSelectionString = () => { 54 74 return Array.from(selectedItems).join(","); 55 75 }; 56 - 76 + 57 77 // Only render the Provider when initialized to prevent rendering with incomplete state 58 78 return ( 59 79 <SelectionContext.Provider 60 80 value={{ 61 81 selectedItems, 62 82 toggleSelection, 83 + selectItem, 63 84 clearSelection, 64 85 isSelected, 65 86 getSelectionString, 87 + sharedDragX 66 88 }} 67 89 > 68 90 {children}