feat(poweruser): keyboard-first task lists + bulk ops + calendar nav

- j/k keyboard navigation in task list view with visual selection ring
- Enter to open task, e for inline title edit, x to toggle complete
- Shift+J/K to reorder tasks in list view
- Bulk selection via Shift+Click/Cmd+Click with checkbox column
- BulkActionBar: complete, set priority, set state, add tag, delete
- Calendar arrow key navigation, t=today, n=new event
- kbd hint legend on first load
This commit is contained in:
2026-09-09 01:31:59 +00:00
parent c98e1c69f2
commit db1a2156af
6 changed files with 490 additions and 10 deletions
@@ -0,0 +1,3 @@
export function QuickCapture() {
return null;
}
@@ -0,0 +1,3 @@
export function StatusBar() {
return null;
}
@@ -0,0 +1,152 @@
import { useState, useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Trash2, X } from "lucide-react";
import type { Task, State, StateGroup } from "@/lib/types";
import { cn } from "@/lib/utils";
interface BulkActionBarProps {
selectedCount: number;
selectedIds: string[];
projectStates: State[];
onClearSelection: () => void;
onComplete: () => void;
onBatchUpdate: (updates: Partial<Task>) => void;
onBatchDelete: () => void;
}
export function BulkActionBar({
selectedCount,
selectedIds,
projectStates,
onClearSelection,
onComplete,
onBatchUpdate,
onBatchDelete,
}: BulkActionBarProps) {
const [priority, setPriority] = useState<string>("");
const [stateId, setStateId] = useState<string>("");
const [tagName, setTagName] = useState<string>("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const tagInputRef = useRef<HTMLInputElement>(null);
const handlePriorityChange = (value: string) => {
setPriority(value);
if (value) {
onBatchUpdate({ priority: value as Task["priority"] });
}
};
const handleStateChange = (value: string) => {
setStateId(value);
if (value) {
onBatchUpdate({ stateId: value || null });
}
};
const handleTagAdd = () => {
if (tagName.trim()) {
onBatchUpdate({ tags: [{ id: "", name: tagName.trim(), color: null }] });
setTagName("");
tagInputRef.current?.focus();
}
};
const handleDeleteConfirm = () => {
onBatchDelete();
setDeleteDialogOpen(false);
};
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 shadow-lg rounded-lg bg-card border px-4 py-2 flex items-center gap-2">
<span className="text-sm font-medium mr-2">{selectedCount} selected</span>
<Button variant="outline" size="sm" onClick={onComplete}>
Complete
</Button>
<Select value={priority} onValueChange={handlePriorityChange}>
<SelectTrigger className="w-28 h-8 text-xs">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
{projectStates.length > 0 && (
<Select value={stateId} onValueChange={handleStateChange}>
<SelectTrigger className="w-32 h-8 text-xs">
<SelectValue placeholder="State" />
</SelectTrigger>
<SelectContent>
{projectStates.map((s) => (
<SelectItem key={s.id} value={s.id}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color || "#94a3b8" }} />
{s.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="flex items-center gap-1">
<Input
ref={tagInputRef}
value={tagName}
onChange={(e) => setTagName(e.target.value)}
placeholder="Tag"
className="w-24 h-8 text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleTagAdd();
}
}}
/>
<Button variant="ghost" size="sm" onClick={handleTagAdd} className="h-8 px-2">
+
</Button>
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedCount} tasks</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {selectedCount} tasks? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-destructive text-destructive-foreground">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button variant="ghost" size="sm" onClick={onClearSelection} className="h-8 px-2 ml-2">
<X className="h-4 w-4 mr-1" />
Esc
</Button>
</div>
);
}
+4 -3
View File
@@ -5,14 +5,13 @@ import { Sidebar } from "@/components/shell/sidebar";
import { Topbar } from "@/components/shell/topbar"; import { Topbar } from "@/components/shell/topbar";
import { CommandPalette } from "@/components/shell/command-palette"; import { CommandPalette } from "@/components/shell/command-palette";
import { ShortcutsHelp } from "@/components/shell/shortcuts-help"; import { ShortcutsHelp } from "@/components/shell/shortcuts-help";
import { QuickCapture } from "@/components/shell/quick-capture";
import { StatusBar } from "@/components/shell/status-bar";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
function AppLayout() { function AppLayout() {
useKeyboardShortcuts(); useKeyboardShortcuts();
// Apply persisted appearance preferences (density, reduced motion, font size)
// right after the first paint. The settings page updates these live while
// open; this covers reloads where the settings page was never visited.
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
root.classList.remove("density-compact", "density-spacious", "reduce-motion"); root.classList.remove("density-compact", "density-spacious", "reduce-motion");
@@ -38,9 +37,11 @@ function AppLayout() {
> >
<Outlet /> <Outlet />
</main> </main>
<StatusBar />
</div> </div>
<CommandPalette /> <CommandPalette />
<ShortcutsHelp /> <ShortcutsHelp />
<QuickCapture />
</div> </div>
); );
} }
+59 -1
View File
@@ -171,6 +171,7 @@ function CalendarPage() {
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null); const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
const [eventDetailOpen, setEventDetailOpen] = useState(false); const [eventDetailOpen, setEventDetailOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [selectedEventIndex, setSelectedEventIndex] = useState<number>(-1);
useRealtime({ enabled: true }); useRealtime({ enabled: true });
@@ -183,7 +184,6 @@ function CalendarPage() {
return () => window.removeEventListener("resize", check); return () => window.removeEventListener("resize", check);
}, []); }, []);
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const [showTasks, setShowTasks] = useState(true); const [showTasks, setShowTasks] = useState(true);
const [showHabits, setShowHabits] = useState(true); const [showHabits, setShowHabits] = useState(true);
@@ -227,6 +227,64 @@ function CalendarPage() {
})); }));
}, [events]); }, [events]);
// Keyboard navigation for calendar
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable ||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
) {
return;
}
switch (e.key) {
case "ArrowLeft":
e.preventDefault();
setDate((d) => addDays(d, -1));
break;
case "ArrowRight":
e.preventDefault();
setDate((d) => addDays(d, 1));
break;
case "ArrowUp":
e.preventDefault();
setDate((d) => addDays(d, -7));
break;
case "ArrowDown":
e.preventDefault();
setDate((d) => addDays(d, 7));
break;
case "t":
e.preventDefault();
setDate(new Date());
break;
case "n":
e.preventDefault();
setCreateOpen(true);
break;
case "Enter":
if (selectedEventIndex >= 0 && selectedEventIndex < calendarEvents.length) {
e.preventDefault();
setSelectedEvent(calendarEvents[selectedEventIndex]);
setEventDetailOpen(true);
}
break;
case "Escape":
if (eventDetailOpen) {
setEventDetailOpen(false);
}
break;
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [calendarEvents, selectedEventIndex, eventDetailOpen]);
const handleSelectEvent = useCallback((event: any) => { const handleSelectEvent = useCallback((event: any) => {
setSelectedEvent(event as CalendarEvent); setSelectedEvent(event as CalendarEvent);
setEventDetailOpen(true); setEventDetailOpen(true);
+269 -6
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { createRoute, useNavigate } from "@tanstack/react-router"; import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -10,6 +10,7 @@ import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor,
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal } from "lucide-react"; import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -23,6 +24,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel"; import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { LoadingState, EmptyState, ErrorState } from "@/components/state"; import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs"; import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
import { PRIORITY } from "@/lib/status-colors"; import { PRIORITY } from "@/lib/status-colors";
@@ -30,6 +32,7 @@ import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp"; import { parseTaskInput } from "@/lib/nlp";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker"; import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
import { BulkActionBar } from "@/components/tasks/bulk-action-bar";
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [ const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
{ id: "backlog", label: "Backlog", colorClass: "bg-slate-400" }, { id: "backlog", label: "Backlog", colorClass: "bg-slate-400" },
@@ -247,6 +250,11 @@ function TasksPage() {
const [selectedTask, setSelectedTask] = useState<Task | null>(null); const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(new Set());
const [showKbdHint, setShowKbdHint] = useState(true);
const [editTitle, setEditTitle] = useState<string>("");
useRealtime({ enabled: true }); useRealtime({ enabled: true });
@@ -299,6 +307,126 @@ function TasksPage() {
const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0); const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0);
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false); const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
// Keyboard navigation for list view
useEffect(() => {
if (view !== "list") return;
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable ||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
) {
return;
}
setShowKbdHint(false);
const hasSelection = selectedTaskIds.size > 0;
const currentIdx = selectedRowIndex >= 0 ? selectedRowIndex : 0;
switch (e.key) {
case "j":
if (!e.shiftKey) {
e.preventDefault();
setSelectedRowIndex((prev) => {
const next = Math.min(prev + 1, tasks.length - 1);
const row = document.querySelector(`[data-row-index="${next}"]`);
row?.scrollIntoView({ block: "nearest", behavior: "smooth" });
return next;
});
}
break;
case "k":
if (!e.shiftKey) {
e.preventDefault();
setSelectedRowIndex((prev) => {
const next = Math.max(prev - 1, 0);
const row = document.querySelector(`[data-row-index="${next}"]`);
row?.scrollIntoView({ block: "nearest", behavior: "smooth" });
return next;
});
}
break;
case "J":
if (e.shiftKey) {
e.preventDefault();
if (tasks.length > 1) {
const newOrder = tasks.map((t) => t.id);
const idx = currentIdx;
const nextIdx = Math.min(idx + 1, tasks.length - 1);
if (idx !== nextIdx) {
[newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]];
reorderMutation.mutate({ orderedIds: newOrder });
setSelectedRowIndex(nextIdx);
}
}
}
break;
case "K":
if (e.shiftKey) {
e.preventDefault();
if (tasks.length > 1) {
const newOrder = tasks.map((t) => t.id);
const idx = currentIdx;
const nextIdx = Math.max(idx - 1, 0);
if (idx !== nextIdx) {
[newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]];
reorderMutation.mutate({ orderedIds: newOrder });
setSelectedRowIndex(nextIdx);
}
}
}
break;
case "Enter":
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
e.preventDefault();
openTaskPanel(tasks[selectedRowIndex]);
}
break;
case "e":
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
e.preventDefault();
const task = tasks[selectedRowIndex];
setEditingTaskId(task.id);
setEditTitle(task.title);
}
break;
case "x":
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
e.preventDefault();
const task = tasks[selectedRowIndex];
const newStatus = task.status === "done" ? "todo" : "done";
stateUpdateMutation.mutate({ taskId: task.id, stateId: task.stateId || "" });
api.patch(`/tasks/${task.id}`, { status: newStatus }).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
});
}
break;
case "Escape":
if (hasSelection) {
setSelectedTaskIds(new Set());
} else {
setSelectedRowIndex(-1);
}
break;
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [view, tasks, selectedRowIndex, selectedTaskIds]);
// Hide kbd hint after 5 seconds
useEffect(() => {
if (!showKbdHint) return;
const timer = setTimeout(() => setShowKbdHint(false), 5000);
return () => clearTimeout(timer);
}, [showKbdHint]);
const loadMoreTasks = async () => { const loadMoreTasks = async () => {
if (!hasMoreTasks || loadingMoreTasks) return; if (!hasMoreTasks || loadingMoreTasks) return;
setLoadingMoreTasks(true); setLoadingMoreTasks(true);
@@ -338,6 +466,52 @@ function TasksPage() {
}, },
}); });
// Bulk action handlers
const handleBulkComplete = useCallback(() => {
const ids = Array.from(selectedTaskIds);
Promise.all(ids.map((id) => api.patch(`/tasks/${id}`, { status: "done" }))).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setSelectedTaskIds(new Set());
toast.success(`${ids.length} tasks marked as done`);
});
}, [selectedTaskIds, queryClient]);
const handleBulkUpdate = useCallback((updates: Partial<Task>) => {
const ids = Array.from(selectedTaskIds);
Promise.all(ids.map((id) => api.patch(`/tasks/${id}`, updates))).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setSelectedTaskIds(new Set());
toast.success(`${ids.length} tasks updated`);
});
}, [selectedTaskIds, queryClient]);
const handleBulkDelete = useCallback(() => {
const ids = Array.from(selectedTaskIds);
Promise.all(ids.map((id) => api.delete(`/tasks/${id}`))).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setSelectedTaskIds(new Set());
toast.success(`${ids.length} tasks deleted`);
});
}, [selectedTaskIds, queryClient]);
const handleSaveEdit = useCallback(() => {
if (editingTaskId && editTitle.trim()) {
api.patch(`/tasks/${editingTaskId}`, { title: editTitle.trim() }).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setEditingTaskId(null);
setEditTitle("");
});
}
}, [editingTaskId, editTitle, queryClient]);
const toggleAll = useCallback(() => {
if (selectedTaskIds.size === tasks.length) {
setSelectedTaskIds(new Set());
} else {
setSelectedTaskIds(new Set(tasks.map((t) => t.id)));
}
}, [selectedTaskIds, tasks]);
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/tasks/" + id), mutationFn: (id: string) => api.delete("/tasks/" + id),
onSuccess: () => { onSuccess: () => {
@@ -551,10 +725,26 @@ function TasksPage() {
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
) : ( ) : (
<div className="border rounded-lg"> <div className="border rounded-lg relative">
{view === "list" && showKbdHint && (
<div className="absolute top-0 right-0 text-[10px] text-muted-foreground flex gap-3 p-2 z-10">
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">j</kbd>/<kbd className="px-1 py-0.5 rounded border bg-muted font-mono">k</kbd> navigate</span>
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">Enter</kbd> open</span>
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">e</kbd> edit</span>
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">x</kbd> toggle</span>
</div>
)}
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{selectedTaskIds.size > 0 && (
<TableHead className="w-10">
<Checkbox
checked={selectedTaskIds.size === tasks.length && tasks.length > 0}
onCheckedChange={toggleAll}
/>
</TableHead>
)}
<TableHead>Title</TableHead> <TableHead>Title</TableHead>
<TableHead>State</TableHead> <TableHead>State</TableHead>
<TableHead>Priority</TableHead> <TableHead>Priority</TableHead>
@@ -565,15 +755,77 @@ function TasksPage() {
<TableBody> <TableBody>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5}> <TableCell colSpan={selectedTaskIds.size > 0 ? 6 : 5}>
<EmptyState title="No tasks found" /> <EmptyState title="No tasks found" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : tasks.map((task) => { ) : tasks.map((task, idx) => {
const st = task.stateId ? stateById.get(task.stateId) : undefined; const st = task.stateId ? stateById.get(task.stateId) : undefined;
return ( return (
<TableRow key={task.id} className="cursor-pointer hover:bg-muted/30 py-2" onClick={() => openTaskDetail(task)}> <TableRow
<TableCell className="font-medium py-2">{task.title}</TableCell> key={task.id}
data-row-index={idx}
className={cn(
"cursor-pointer hover:bg-muted/30 py-2",
idx === selectedRowIndex && "bg-accent/50 ring-1 ring-[hsl(var(--accent-hsl))]"
)}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
setSelectedTaskIds((prev) => {
const next = new Set(prev);
if (next.has(task.id)) {
next.delete(task.id);
} else {
next.add(task.id);
}
return next;
});
} else {
openTaskDetail(task);
}
}}
>
{selectedTaskIds.size > 0 && (
<TableCell className="w-10 py-2">
<Checkbox
checked={selectedTaskIds.has(task.id)}
onCheckedChange={(checked) => {
setSelectedTaskIds((prev) => {
const next = new Set(prev);
if (checked) {
next.add(task.id);
} else {
next.delete(task.id);
}
return next;
});
}}
/>
</TableCell>
)}
<TableCell className="font-medium py-2">
{editingTaskId === task.id ? (
<Input
autoFocus
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="h-7 text-sm"
onBlur={handleSaveEdit}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
handleSaveEdit();
}
if (ev.key === "Escape") {
setEditingTaskId(null);
setEditTitle("");
}
}}
/>
) : (
<span className="font-medium">{task.title}</span>
)}
</TableCell>
<TableCell className="py-2"> <TableCell className="py-2">
{st ? ( {st ? (
<Badge variant="secondary" className="font-mono text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}> <Badge variant="secondary" className="font-mono text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
@@ -607,6 +859,17 @@ function TasksPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
{selectedTaskIds.size > 0 && (
<BulkActionBar
selectedCount={selectedTaskIds.size}
selectedIds={Array.from(selectedTaskIds)}
projectStates={projectStates}
onClearSelection={() => setSelectedTaskIds(new Set())}
onComplete={handleBulkComplete}
onBatchUpdate={handleBulkUpdate}
onBatchDelete={handleBulkDelete}
/>
)}
</div> </div>
)} )}