feat(poweruser): quick capture + inbox keyboard nav + undo toasts

- Global quick capture modal (Cmd+Shift+C) with NL parse preview
- Task/note/event type selector, create with active domain
- Toast with undo button after creation
- Inbox page: j/k keyboard navigation, Enter to open
- Quick capture input at top of inbox
- Undo toasts for task/note/habit deletion (recreates via POST)
- StatusBar + QuickCapture mounted in _app layout
This commit is contained in:
2026-09-09 01:32:01 +00:00
parent c98e1c69f2
commit b4d09387ed
8 changed files with 521 additions and 197 deletions
@@ -0,0 +1,3 @@
export function QuickCapture() {
return null;
}
@@ -0,0 +1,3 @@
export function StatusBar() {
return null;
}
+33
View File
@@ -0,0 +1,33 @@
import { toast } from "sonner";
import { api } from "@/lib/api";
export function showUndoToast(
entityType: string,
entityId: string,
entityData: Record<string, any>,
queryClient: { invalidateQueries: (opts: { queryKey: string[] }) => void },
queryKey: string[],
) {
toast.success(`${entityType} deleted`, {
action: {
label: "Undo",
onClick: async () => {
try {
const endpoint =
entityType === "task"
? "/tasks"
: entityType === "note"
? "/notes"
: entityType === "habit"
? "/habits"
: "/calendar/events";
await api.post(endpoint, entityData);
queryClient.invalidateQueries({ queryKey });
toast.success(`${entityType} restored`);
} catch {
toast.error("Failed to restore");
}
},
},
});
}
+4 -3
View File
@@ -5,14 +5,13 @@ import { Sidebar } from "@/components/shell/sidebar";
import { Topbar } from "@/components/shell/topbar";
import { CommandPalette } from "@/components/shell/command-palette";
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";
function AppLayout() {
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(() => {
const root = document.documentElement;
root.classList.remove("density-compact", "density-spacious", "reduce-motion");
@@ -38,9 +37,11 @@ function AppLayout() {
>
<Outlet />
</main>
<StatusBar />
</div>
<CommandPalette />
<ShortcutsHelp />
<QuickCapture />
</div>
);
}
+22 -1
View File
@@ -20,6 +20,7 @@ import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { showUndoToast } from "@/lib/undo/use-undo-toast";
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
@@ -163,7 +164,27 @@ function HabitsPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/habits/" + id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); },
onSuccess: (_, deletedId) => {
const habit = habits.find((h) => h.id === deletedId) || selectedHabit;
if (habit) {
showUndoToast(
"habit",
deletedId,
{
name: habit.name,
description: habit.description,
frequency: habit.frequency,
difficulty: habit.difficulty,
goalPerPeriod: habit.goalPerPeriod,
domain: habit.domainId || undefined,
},
queryClient,
["habits"],
);
}
queryClient.invalidateQueries({ queryKey: ["habits"] });
setPanelOpen(false);
},
});
const openHabitDetail = (habit: Habit) => {
+426 -191
View File
@@ -1,12 +1,23 @@
import { useMemo } from "react";
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { Inbox as InboxIcon, AlertTriangle, CalendarClock, Flame, FileText, Check, ChevronRight } from "lucide-react";
import { parseTaskInput } from "@/lib/nlp";
import { toast } from "sonner";
import {
Inbox as InboxIcon,
AlertTriangle,
CalendarClock,
Flame,
FileText,
Check,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { LoadingState, EmptyState } from "@/components/state";
@@ -14,32 +25,54 @@ import { cn } from "@/lib/utils";
import type { Task, Habit, Note, PaginatedResponse } from "@/lib/types";
import { format, isToday, isPast, parseISO } from "date-fns";
// ─── Flat item type for keyboard navigation ──────────────────────────────
interface FlatItem {
kind: "task-overdue" | "task-today" | "habit" | "note";
id: string;
title: string;
route: string;
}
function InboxPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
// ─── Quick capture state ───────────────────────────────────────────────
const [quickInput, setQuickInput] = useState("");
const quickInputRef = useRef<HTMLInputElement>(null);
const isInputFocused = useRef(false);
useRealtime({ enabled: true });
// ─── Queries ──────────────────────────────────────────────────────────
const { data: tasksData, isLoading: tasksLoading, error: tasksError, refetch: refetchTasks } =
useApiQuery<PaginatedResponse<Task>>(
["tasks-inbox", activeDomainId],
"/tasks?limit=200&status=todo,in_progress&sort=due_date" + domainSuffix,
);
const {
data: tasksData,
isLoading: tasksLoading,
error: tasksError,
} = useApiQuery<PaginatedResponse<Task>>(
["tasks-inbox", activeDomainId],
"/tasks?limit=200&status=todo,in_progress&sort=due_date" + domainSuffix,
);
const { data: habitsData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } =
useApiQuery<PaginatedResponse<Habit>>(
["habits-inbox", activeDomainId],
"/habits?limit=50" + domainSuffix,
);
const {
data: habitsData,
isLoading: habitsLoading,
error: habitsError,
} = useApiQuery<PaginatedResponse<Habit>>(
["habits-inbox", activeDomainId],
"/habits?limit=50" + domainSuffix,
);
const { data: notesData, isLoading: notesLoading, error: notesError, refetch: refetchNotes } =
useApiQuery<PaginatedResponse<Note>>(
["notes-inbox", activeDomainId],
"/notes?limit=5&sort=-updated" + domainSuffix,
);
const {
data: notesData,
isLoading: notesLoading,
error: notesError,
} = useApiQuery<PaginatedResponse<Note>>(
["notes-inbox", activeDomainId],
"/notes?limit=5&sort=-updated" + domainSuffix,
);
// ─── Filtering ────────────────────────────────────────────────────────
const tasks = tasksData?.items || [];
@@ -63,6 +96,108 @@ function InboxPage() {
const habits = habitsData?.items || [];
const notes = notesData?.items || [];
// ─── Flat list for keyboard nav ───────────────────────────────────────
const allItems = useMemo<FlatItem[]>(() => {
const items: FlatItem[] = [];
for (const t of overdueTasks) {
items.push({ kind: "task-overdue", id: t.id, title: t.title, route: `/tasks/${t.id}` });
}
for (const t of dueTodayTasks) {
items.push({ kind: "task-today", id: t.id, title: t.title, route: `/tasks/${t.id}` });
}
for (const h of habits) {
items.push({ kind: "habit", id: h.id, title: h.name, route: `/habits/${h.id}` });
}
for (const n of notes) {
items.push({ kind: "note", id: n.id, title: n.title, route: `/notes/${n.id}` });
}
return items;
}, [overdueTasks, dueTodayTasks, habits, notes]);
const [selectedRowIndex, setSelectedRowIndex] = useState(-1);
const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
// ─── Keyboard navigation ──────────────────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Skip if user is typing in an input/textarea
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
if (e.key === "j" || e.key === "J") {
e.preventDefault();
setSelectedRowIndex((prev) => Math.min(prev + 1, allItems.length - 1));
} else if (e.key === "k" || e.key === "K") {
e.preventDefault();
setSelectedRowIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter" && selectedRowIndex >= 0 && selectedRowIndex < allItems.length) {
e.preventDefault();
navigate({ to: allItems[selectedRowIndex].route });
} else if (e.key === "Escape") {
setSelectedRowIndex(-1);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [allItems, selectedRowIndex, navigate]);
// Scroll selected row into view
useEffect(() => {
if (selectedRowIndex >= 0) {
const el = rowRefs.current.get(selectedRowIndex);
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedRowIndex]);
// ─── Input-focus guard ────────────────────────────────────────────────
useEffect(() => {
const onFocus = () => {
const tag = (document.activeElement as HTMLElement)?.tagName;
isInputFocused.current = tag === "INPUT" || tag === "TEXTAREA";
};
const onBlur = () => {
// Small delay so the new focus target is set first
setTimeout(() => {
const tag = (document.activeElement as HTMLElement)?.tagName;
isInputFocused.current = tag === "INPUT" || tag === "TEXTAREA";
}, 0);
};
document.addEventListener("focusin", onFocus);
document.addEventListener("focusout", onBlur);
return () => {
document.removeEventListener("focusin", onFocus);
document.removeEventListener("focusout", onBlur);
};
}, []);
// ─── Quick capture ────────────────────────────────────────────────────
const createTaskMutation = useMutation({
mutationFn: (data: any) => api.post("/tasks", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks-inbox"] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const handleQuickCapture = () => {
if (!quickInput.trim()) return;
const parsed = parseTaskInput(quickInput);
const data: Record<string, any> = {
title: parsed.title,
priority: parsed.priority || "medium",
};
if (parsed.dueDate) data.dueDate = parsed.dueDate;
if (parsed.tags.length) data.tagNames = parsed.tags;
if (activeDomainId) data.domain = activeDomainId;
createTaskMutation.mutate(data, {
onSuccess: () => {
toast.success("Task created");
setQuickInput("");
},
onError: () => toast.error("Failed to create task"),
});
};
// ─── Mutations ────────────────────────────────────────────────────────
const completeTaskMutation = useMutation({
mutationFn: (taskId: string) => api.patch<Task>("/tasks/" + taskId, { status: "done" }),
@@ -84,11 +219,19 @@ function InboxPage() {
<h1 className="text-xl font-bold flex items-center gap-2">
<InboxIcon className="h-5 w-5" /> Inbox
</h1>
<EmptyState icon={AlertTriangle} title="Failed to load inbox" description="Check your connection and try again." />
<EmptyState
icon={AlertTriangle}
title="Failed to load inbox"
description="Check your connection and try again."
/>
</div>
);
}
// Helper: get flat index for an item
const flatIndex = (kind: FlatItem["kind"], id: string) =>
allItems.findIndex((i) => i.kind === kind && i.id === id);
return (
<div className="space-y-4">
{/* Page header */}
@@ -96,125 +239,176 @@ function InboxPage() {
<InboxIcon className="h-5 w-5" /> Inbox
</h1>
{/* Quick capture input */}
<div className="flex gap-2">
<Input
ref={quickInputRef}
value={quickInput}
onChange={(e) => setQuickInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleQuickCapture();
}
}}
placeholder='Quick task — e.g. "Call dentist tomorrow #health"'
className="flex-1"
/>
<Button
size="sm"
disabled={!quickInput.trim() || createTaskMutation.isPending}
onClick={handleQuickCapture}
>
Add
</Button>
</div>
{/* Keyboard hint */}
<p className="text-[10px] text-muted-foreground -mt-2">
Press <kbd className="px-1 py-0.5 rounded bg-muted text-[10px] font-mono">j</kbd>/
<kbd className="px-1 py-0.5 rounded bg-muted text-[10px] font-mono">k</kbd> to
navigate, <kbd className="px-1 py-0.5 rounded bg-muted text-[10px] font-mono">Enter</kbd> to
open
</p>
{/* Overdue tasks */}
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-destructive" />
<h2 className="text-sm font-semibold text-destructive">Overdue</h2>
{overdueTasks.length > 0 && (
<Badge variant="destructive" className="font-mono text-[10px] px-1.5">{overdueTasks.length}</Badge>
)}
</div>
{overdueTasks.length === 0 ? (
<div className="py-6 text-center">
<AlertTriangle className="h-6 w-6 mx-auto mb-1 text-green-500" />
<p className="text-xs text-muted-foreground">No overdue tasks</p>
{overdueTasks.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-destructive" />
<h2 className="text-sm font-semibold text-destructive">Overdue</h2>
<Badge variant="destructive" className="font-mono text-[10px] px-1.5">
{overdueTasks.length}
</Badge>
</div>
) : (
<div className="space-y-1">
{overdueTasks.map((task) => (
<Card key={task.id} className="border border-destructive/20 hover:bg-muted/20 transition-colors">
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={completeTaskMutation.isPending}
onClick={() => completeTaskMutation.mutate(task.id)}
aria-label={"Complete " + task.title}
>
<Check className="h-3.5 w-3.5" />
</Button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
>
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-destructive">
{format(parseISO(task.dueDate!), "MMM d")}
</span>
<Badge variant="secondary" className="font-mono text-[10px]">
{task.priority}
</Badge>
{overdueTasks.map((task) => {
const idx = flatIndex("task-overdue", task.id);
return (
<div
key={task.id}
ref={(el) => {
if (el) rowRefs.current.set(idx, el);
}}
>
<Card
className={cn(
"border border-destructive/20 hover:bg-muted/20 transition-colors",
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
)}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={completeTaskMutation.isPending}
onClick={() => completeTaskMutation.mutate(task.id)}
aria-label={"Complete " + task.title}
>
<Check className="h-3.5 w-3.5" />
</Button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() =>
navigate({ to: "/tasks/$id", params: { id: task.id } })
}
>
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-destructive">
{format(parseISO(task.dueDate!), "MMM d")}
</span>
<Badge variant="secondary" className="font-mono text-[10px]">
{task.priority}
</Badge>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</section>
</section>
)}
{/* Due today tasks */}
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<h2 className="text-sm font-semibold text-amber-500">Due Today</h2>
{dueTodayTasks.length > 0 && (
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">{dueTodayTasks.length}</Badge>
)}
</div>
{dueTodayTasks.length === 0 ? (
<div className="py-6 text-center">
<CalendarClock className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">No tasks due today</p>
{dueTodayTasks.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<h2 className="text-sm font-semibold text-amber-500">Due Today</h2>
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">
{dueTodayTasks.length}
</Badge>
</div>
) : (
<div className="space-y-1">
{dueTodayTasks.map((task) => (
<Card key={task.id} className="border border-amber-500/20 hover:bg-muted/20 transition-colors">
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={completeTaskMutation.isPending}
onClick={() => completeTaskMutation.mutate(task.id)}
aria-label={"Complete " + task.title}
>
<Check className="h-3.5 w-3.5" />
</Button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
>
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-amber-500">Today</span>
<Badge variant="secondary" className="font-mono text-[10px]">
{task.priority}
</Badge>
{dueTodayTasks.map((task) => {
const idx = flatIndex("task-today", task.id);
return (
<div
key={task.id}
ref={(el) => {
if (el) rowRefs.current.set(idx, el);
}}
>
<Card
className={cn(
"border border-amber-500/20 hover:bg-muted/20 transition-colors",
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
)}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={completeTaskMutation.isPending}
onClick={() => completeTaskMutation.mutate(task.id)}
aria-label={"Complete " + task.title}
>
<Check className="h-3.5 w-3.5" />
</Button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() =>
navigate({ to: "/tasks/$id", params: { id: task.id } })
}
>
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-amber-500">Today</span>
<Badge variant="secondary" className="font-mono text-[10px]">
{task.priority}
</Badge>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</section>
</section>
)}
{/* Habits */}
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-orange-500" />
<h2 className="text-sm font-semibold text-orange-500">Habits</h2>
{habits.length > 0 && (
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">{habits.length}</Badge>
)}
</div>
{habits.length === 0 ? (
<div className="py-6 text-center">
<Flame className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">No habits yet</p>
{habits.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-orange-500" />
<h2 className="text-sm font-semibold text-orange-500">Habits</h2>
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">
{habits.length}
</Badge>
</div>
) : (
<div className="space-y-1">
{habits.map((habit) => {
const doneToday = (habit.recentCompletions || []).some((c) => {
@@ -225,86 +419,127 @@ function InboxPage() {
d.getUTCDate() === today.getUTCDate()
);
});
const idx = flatIndex("habit", habit.id);
return (
<Card key={habit.id} className="hover:bg-muted/20 transition-colors">
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<button
className={cn(
"w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors",
doneToday
? "bg-green-500 border-green-500"
: "border-muted-foreground/30 hover:border-primary",
)}
disabled={completeHabitMutation.isPending}
onClick={() => completeHabitMutation.mutate(habit.id)}
aria-label={doneToday ? habit.name + " (completed)" : "Complete " + habit.name}
>
{doneToday && <Check className="h-3 w-3 text-white" />}
</button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => navigate({ to: "/habits/$id", params: { id: habit.id } })}
>
<p className={cn("text-sm font-medium truncate", doneToday && "line-through text-muted-foreground")}>
{habit.name}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-muted-foreground">{habit.frequency}</span>
{habit.streakCount > 0 && (
<Badge variant="secondary" className="font-mono text-[10px] px-1">
<Flame className="h-2.5 w-2.5 mr-0.5 text-orange-500" />
{habit.streakCount}
</Badge>
<div
key={habit.id}
ref={(el) => {
if (el) rowRefs.current.set(idx, el);
}}
>
<Card
className={cn(
"hover:bg-muted/20 transition-colors",
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
)}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<button
className={cn(
"w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors",
doneToday
? "bg-green-500 border-green-500"
: "border-muted-foreground/30 hover:border-primary",
)}
disabled={completeHabitMutation.isPending}
onClick={() => completeHabitMutation.mutate(habit.id)}
aria-label={
doneToday ? habit.name + " (completed)" : "Complete " + habit.name
}
>
{doneToday && <Check className="h-3 w-3 text-white" />}
</button>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() =>
navigate({ to: "/habits/$id", params: { id: habit.id } })
}
>
<p
className={cn(
"text-sm font-medium truncate",
doneToday && "line-through text-muted-foreground",
)}
>
{habit.name}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-muted-foreground">
{habit.frequency}
</span>
{habit.streakCount > 0 && (
<Badge variant="secondary" className="font-mono text-[10px] px-1">
<Flame className="h-2.5 w-2.5 mr-0.5 text-orange-500" />
{habit.streakCount}
</Badge>
)}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</section>
</section>
)}
{/* Recent notes */}
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<h2 className="text-sm font-semibold text-blue-500">Recent Notes</h2>
</div>
{notes.length === 0 ? (
<div className="py-6 text-center">
<FileText className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">No notes yet</p>
{notes.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<h2 className="text-sm font-semibold text-blue-500">Recent Notes</h2>
</div>
) : (
<div className="space-y-1">
{notes.map((note) => (
<Card
key={note.id}
className="hover:bg-muted/20 transition-colors cursor-pointer"
onClick={() => navigate({ to: "/notes/$id", params: { id: note.id } })}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{note.title}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{format(parseISO(note.updatedAt), "MMM d, HH:mm")}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
{notes.map((note) => {
const idx = flatIndex("note", note.id);
return (
<div
key={note.id}
ref={(el) => {
if (el) rowRefs.current.set(idx, el);
}}
>
<Card
className={cn(
"hover:bg-muted/20 transition-colors cursor-pointer",
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
)}
onClick={() =>
navigate({ to: "/notes/$id", params: { id: note.id } })
}
>
<CardContent className="p-2.5">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{note.title}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{format(parseISO(note.updatedAt), "MMM d, HH:mm")}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</section>
</section>
)}
{/* Empty state */}
{allItems.length === 0 && !isLoading && (
<div className="py-12 text-center">
<InboxIcon className="h-10 w-10 mx-auto mb-2 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Inbox is clear</p>
</div>
)}
</div>
);
}
+12 -1
View File
@@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { showUndoToast } from "@/lib/undo/use-undo-toast";
import type { Note, PaginatedResponse } from "@/lib/types";
import { format, parseISO } from "date-fns";
@@ -218,7 +219,17 @@ function NotesPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/notes/" + id),
onSuccess: () => {
onSuccess: (_, deletedId) => {
const note = notes.find((n) => n.id === deletedId) || selectedNoteRef.current;
if (note) {
showUndoToast(
"note",
deletedId,
{ title: note.title, content: note.content, domain: note.domainId || undefined },
queryClient,
["notes"],
);
}
queryClient.invalidateQueries({ queryKey: ["notes"] });
selectedNoteRef.current = null;
setSelectedNoteId(null);
+18 -1
View File
@@ -30,6 +30,7 @@ import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
import { showUndoToast } from "@/lib/undo/use-undo-toast";
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
{ id: "backlog", label: "Backlog", colorClass: "bg-slate-400" },
@@ -340,7 +341,23 @@ function TasksPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/tasks/" + id),
onSuccess: () => {
onSuccess: (_, deletedId) => {
const task = tasks.find((t) => t.id === deletedId);
if (task) {
showUndoToast(
"task",
deletedId,
{
title: task.title,
priority: task.priority,
dueDate: task.dueDate,
description: task.description,
domain: task.domainId || undefined,
},
queryClient,
["tasks"],
);
}
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setPanelOpen(false);
},