Merge branch 'feat/poweruser-capture'
# Conflicts: # apps/web/src/routes/_app/tasks.tsx
This commit is contained in:
@@ -1,3 +1,224 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
|
import { parseTaskInput } from "@/lib/nlp";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type EntityType = "task" | "note" | "event";
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: { value: EntityType; label: string }[] = [
|
||||||
|
{ value: "task", label: "Task" },
|
||||||
|
{ value: "note", label: "Note" },
|
||||||
|
{ value: "event", label: "Event" },
|
||||||
|
];
|
||||||
|
|
||||||
export function QuickCapture() {
|
export function QuickCapture() {
|
||||||
return null;
|
const [open, setOpen] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [entityType, setEntityType] = useState<EntityType>("task");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "c") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
} else {
|
||||||
|
setInputValue("");
|
||||||
|
setEntityType("task");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const parsed = entityType === "task" ? parseTaskInput(inputValue) : null;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!inputValue.trim() || isSubmitting) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let createdId: string | null = null;
|
||||||
|
|
||||||
|
if (entityType === "task") {
|
||||||
|
const p = parseTaskInput(inputValue);
|
||||||
|
const data: any = {
|
||||||
|
title: p.title,
|
||||||
|
priority: p.priority || "medium",
|
||||||
|
};
|
||||||
|
if (p.dueDate) data.dueDate = p.dueDate;
|
||||||
|
if (p.tags.length) data.tagNames = p.tags;
|
||||||
|
if (activeDomainId) data.domain = activeDomainId;
|
||||||
|
const res = await api.post<{ id: string }>("/tasks", data);
|
||||||
|
createdId = res.id;
|
||||||
|
toast.success("Task created", {
|
||||||
|
action: {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.delete("/tasks/" + createdId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
toast.success("Task deleted");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to undo");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
} else if (entityType === "note") {
|
||||||
|
const res = await api.post<{ id: string }>("/notes", {
|
||||||
|
title: inputValue.trim(),
|
||||||
|
...(activeDomainId ? { domain: activeDomainId } : {}),
|
||||||
|
});
|
||||||
|
createdId = res.id;
|
||||||
|
toast.success("Note created", {
|
||||||
|
action: {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.delete("/notes/" + createdId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||||
|
toast.success("Note deleted");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to undo");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(now.getTime() + 60 * 60 * 1000);
|
||||||
|
const res = await api.post<{ id: string }>("/calendar/events", {
|
||||||
|
title: inputValue.trim(),
|
||||||
|
startTime: now.toISOString(),
|
||||||
|
endTime: end.toISOString(),
|
||||||
|
...(activeDomainId ? { domain: activeDomainId } : {}),
|
||||||
|
});
|
||||||
|
createdId = res.id;
|
||||||
|
toast.success("Event created", {
|
||||||
|
action: {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.delete("/calendar/events/" + createdId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||||
|
toast.success("Event deleted");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to undo");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputValue("");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to create");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Quick Capture</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TYPE_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setEntityType(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
||||||
|
entityType === opt.value
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
entityType === "task"
|
||||||
|
? 'Try "Buy milk tomorrow 5pm #groceries p1"'
|
||||||
|
: entityType === "note"
|
||||||
|
? "Note title..."
|
||||||
|
: "Event title..."
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{entityType === "task" && parsed && (parsed.dueDate || parsed.priority || parsed.tags.length > 0) && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{parsed.dueDate && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
Due {new Date(parsed.dueDate).toLocaleDateString()}{" "}
|
||||||
|
{new Date(parsed.dueDate).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{parsed.priority && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
Priority {parsed.priority}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{parsed.tags.map((t) => (
|
||||||
|
<Badge key={t} variant="outline" className="text-[10px]">
|
||||||
|
#{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!inputValue.trim() || isSubmitting}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,53 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
|
import { Wifi, WifiOff, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function StatusBar() {
|
export function StatusBar() {
|
||||||
return null;
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [sseStatus, setSseStatus] = useState<"connected" | "reconnecting" | "disconnected">("disconnected");
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource("/api/realtime");
|
||||||
|
const check = () => {
|
||||||
|
if (es.readyState === EventSource.OPEN) setSseStatus("connected");
|
||||||
|
else if (es.readyState === EventSource.CONNECTING) setSseStatus("reconnecting");
|
||||||
|
else setSseStatus("disconnected");
|
||||||
|
};
|
||||||
|
es.onopen = () => setSseStatus("connected");
|
||||||
|
es.onerror = () => check();
|
||||||
|
const interval = setInterval(check, 5000);
|
||||||
|
return () => {
|
||||||
|
es.close();
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t bg-muted/30 px-3 py-1 text-[10px] text-muted-foreground flex items-center gap-4 shrink-0">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{sseStatus === "connected" ? (
|
||||||
|
<Wifi className="h-3 w-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="h-3 w-3 text-amber-500" />
|
||||||
|
)}
|
||||||
|
{sseStatus}
|
||||||
|
</span>
|
||||||
|
{activeDomainId && <span>Domain: {activeDomainId.slice(0, 8)}</span>}
|
||||||
|
<span className="ml-auto">
|
||||||
|
<kbd className="px-1 py-0.5 rounded border bg-muted font-mono">⌘K</kbd>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="hover:text-foreground"
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
|||||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { showUndoToast } from "@/lib/undo/use-undo-toast";
|
||||||
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
|
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
|
||||||
|
|
||||||
function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
||||||
@@ -163,7 +164,27 @@ function HabitsPage() {
|
|||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.delete("/habits/" + id),
|
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) => {
|
const openHabitDetail = (habit: Habit) => {
|
||||||
|
|||||||
+426
-191
@@ -1,12 +1,23 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState, useEffect, useCallback, 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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery } from "@/lib/api";
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { LoadingState, EmptyState } from "@/components/state";
|
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 type { Task, Habit, Note, PaginatedResponse } from "@/lib/types";
|
||||||
import { format, isToday, isPast, parseISO } from "date-fns";
|
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() {
|
function InboxPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const activeDomainId = useApiDomain();
|
const activeDomainId = useApiDomain();
|
||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
|
||||||
|
// ─── Quick capture state ───────────────────────────────────────────────
|
||||||
|
const [quickInput, setQuickInput] = useState("");
|
||||||
|
const quickInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isInputFocused = useRef(false);
|
||||||
|
|
||||||
useRealtime({ enabled: true });
|
useRealtime({ enabled: true });
|
||||||
|
|
||||||
// ─── Queries ──────────────────────────────────────────────────────────
|
// ─── Queries ──────────────────────────────────────────────────────────
|
||||||
const { data: tasksData, isLoading: tasksLoading, error: tasksError, refetch: refetchTasks } =
|
const {
|
||||||
useApiQuery<PaginatedResponse<Task>>(
|
data: tasksData,
|
||||||
["tasks-inbox", activeDomainId],
|
isLoading: tasksLoading,
|
||||||
"/tasks?limit=200&status=todo,in_progress&sort=due_date" + domainSuffix,
|
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 } =
|
const {
|
||||||
useApiQuery<PaginatedResponse<Habit>>(
|
data: habitsData,
|
||||||
["habits-inbox", activeDomainId],
|
isLoading: habitsLoading,
|
||||||
"/habits?limit=50" + domainSuffix,
|
error: habitsError,
|
||||||
);
|
} = useApiQuery<PaginatedResponse<Habit>>(
|
||||||
|
["habits-inbox", activeDomainId],
|
||||||
|
"/habits?limit=50" + domainSuffix,
|
||||||
|
);
|
||||||
|
|
||||||
const { data: notesData, isLoading: notesLoading, error: notesError, refetch: refetchNotes } =
|
const {
|
||||||
useApiQuery<PaginatedResponse<Note>>(
|
data: notesData,
|
||||||
["notes-inbox", activeDomainId],
|
isLoading: notesLoading,
|
||||||
"/notes?limit=5&sort=-updated" + domainSuffix,
|
error: notesError,
|
||||||
);
|
} = useApiQuery<PaginatedResponse<Note>>(
|
||||||
|
["notes-inbox", activeDomainId],
|
||||||
|
"/notes?limit=5&sort=-updated" + domainSuffix,
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Filtering ────────────────────────────────────────────────────────
|
// ─── Filtering ────────────────────────────────────────────────────────
|
||||||
const tasks = tasksData?.items || [];
|
const tasks = tasksData?.items || [];
|
||||||
@@ -63,6 +96,108 @@ function InboxPage() {
|
|||||||
const habits = habitsData?.items || [];
|
const habits = habitsData?.items || [];
|
||||||
const notes = notesData?.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 ────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────
|
||||||
const completeTaskMutation = useMutation({
|
const completeTaskMutation = useMutation({
|
||||||
mutationFn: (taskId: string) => api.patch<Task>("/tasks/" + taskId, { status: "done" }),
|
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">
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||||
<InboxIcon className="h-5 w-5" /> Inbox
|
<InboxIcon className="h-5 w-5" /> Inbox
|
||||||
</h1>
|
</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>
|
</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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
@@ -96,125 +239,176 @@ function InboxPage() {
|
|||||||
<InboxIcon className="h-5 w-5" /> Inbox
|
<InboxIcon className="h-5 w-5" /> Inbox
|
||||||
</h1>
|
</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 */}
|
{/* Overdue tasks */}
|
||||||
<section>
|
{overdueTasks.length > 0 && (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<section>
|
||||||
<div className="h-2 w-2 rounded-full bg-destructive" />
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<h2 className="text-sm font-semibold text-destructive">Overdue</h2>
|
<div className="h-2 w-2 rounded-full bg-destructive" />
|
||||||
{overdueTasks.length > 0 && (
|
<h2 className="text-sm font-semibold text-destructive">Overdue</h2>
|
||||||
<Badge variant="destructive" className="font-mono text-[10px] px-1.5">{overdueTasks.length}</Badge>
|
<Badge variant="destructive" className="font-mono text-[10px] px-1.5">
|
||||||
)}
|
{overdueTasks.length}
|
||||||
</div>
|
</Badge>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{overdueTasks.map((task) => (
|
{overdueTasks.map((task) => {
|
||||||
<Card key={task.id} className="border border-destructive/20 hover:bg-muted/20 transition-colors">
|
const idx = flatIndex("task-overdue", task.id);
|
||||||
<CardContent className="p-2.5">
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<Button
|
key={task.id}
|
||||||
variant="ghost"
|
ref={(el) => {
|
||||||
size="icon"
|
if (el) rowRefs.current.set(idx, el);
|
||||||
className="h-6 w-6 shrink-0"
|
}}
|
||||||
disabled={completeTaskMutation.isPending}
|
>
|
||||||
onClick={() => completeTaskMutation.mutate(task.id)}
|
<Card
|
||||||
aria-label={"Complete " + task.title}
|
className={cn(
|
||||||
>
|
"border border-destructive/20 hover:bg-muted/20 transition-colors",
|
||||||
<Check className="h-3.5 w-3.5" />
|
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
|
||||||
</Button>
|
)}
|
||||||
<div
|
>
|
||||||
className="flex-1 min-w-0 cursor-pointer"
|
<CardContent className="p-2.5">
|
||||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
|
<div className="flex items-center gap-2">
|
||||||
>
|
<Button
|
||||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
variant="ghost"
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
size="icon"
|
||||||
<span className="font-mono text-[10px] text-destructive">
|
className="h-6 w-6 shrink-0"
|
||||||
{format(parseISO(task.dueDate!), "MMM d")}
|
disabled={completeTaskMutation.isPending}
|
||||||
</span>
|
onClick={() => completeTaskMutation.mutate(task.id)}
|
||||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
aria-label={"Complete " + task.title}
|
||||||
{task.priority}
|
>
|
||||||
</Badge>
|
<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>
|
||||||
</div>
|
</CardContent>
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
);
|
||||||
</Card>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
{/* Due today tasks */}
|
{/* Due today tasks */}
|
||||||
<section>
|
{dueTodayTasks.length > 0 && (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<section>
|
||||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<h2 className="text-sm font-semibold text-amber-500">Due Today</h2>
|
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||||
{dueTodayTasks.length > 0 && (
|
<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>
|
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">
|
||||||
)}
|
{dueTodayTasks.length}
|
||||||
</div>
|
</Badge>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{dueTodayTasks.map((task) => (
|
{dueTodayTasks.map((task) => {
|
||||||
<Card key={task.id} className="border border-amber-500/20 hover:bg-muted/20 transition-colors">
|
const idx = flatIndex("task-today", task.id);
|
||||||
<CardContent className="p-2.5">
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<Button
|
key={task.id}
|
||||||
variant="ghost"
|
ref={(el) => {
|
||||||
size="icon"
|
if (el) rowRefs.current.set(idx, el);
|
||||||
className="h-6 w-6 shrink-0"
|
}}
|
||||||
disabled={completeTaskMutation.isPending}
|
>
|
||||||
onClick={() => completeTaskMutation.mutate(task.id)}
|
<Card
|
||||||
aria-label={"Complete " + task.title}
|
className={cn(
|
||||||
>
|
"border border-amber-500/20 hover:bg-muted/20 transition-colors",
|
||||||
<Check className="h-3.5 w-3.5" />
|
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
|
||||||
</Button>
|
)}
|
||||||
<div
|
>
|
||||||
className="flex-1 min-w-0 cursor-pointer"
|
<CardContent className="p-2.5">
|
||||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
|
<div className="flex items-center gap-2">
|
||||||
>
|
<Button
|
||||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
variant="ghost"
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
size="icon"
|
||||||
<span className="font-mono text-[10px] text-amber-500">Today</span>
|
className="h-6 w-6 shrink-0"
|
||||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
disabled={completeTaskMutation.isPending}
|
||||||
{task.priority}
|
onClick={() => completeTaskMutation.mutate(task.id)}
|
||||||
</Badge>
|
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>
|
||||||
</div>
|
</CardContent>
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
);
|
||||||
</Card>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
{/* Habits */}
|
{/* Habits */}
|
||||||
<section>
|
{habits.length > 0 && (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<section>
|
||||||
<div className="h-2 w-2 rounded-full bg-orange-500" />
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<h2 className="text-sm font-semibold text-orange-500">Habits</h2>
|
<div className="h-2 w-2 rounded-full bg-orange-500" />
|
||||||
{habits.length > 0 && (
|
<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>
|
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">
|
||||||
)}
|
{habits.length}
|
||||||
</div>
|
</Badge>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{habits.map((habit) => {
|
{habits.map((habit) => {
|
||||||
const doneToday = (habit.recentCompletions || []).some((c) => {
|
const doneToday = (habit.recentCompletions || []).some((c) => {
|
||||||
@@ -225,86 +419,127 @@ function InboxPage() {
|
|||||||
d.getUTCDate() === today.getUTCDate()
|
d.getUTCDate() === today.getUTCDate()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const idx = flatIndex("habit", habit.id);
|
||||||
return (
|
return (
|
||||||
<Card key={habit.id} className="hover:bg-muted/20 transition-colors">
|
<div
|
||||||
<CardContent className="p-2.5">
|
key={habit.id}
|
||||||
<div className="flex items-center gap-2">
|
ref={(el) => {
|
||||||
<button
|
if (el) rowRefs.current.set(idx, el);
|
||||||
className={cn(
|
}}
|
||||||
"w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors",
|
>
|
||||||
doneToday
|
<Card
|
||||||
? "bg-green-500 border-green-500"
|
className={cn(
|
||||||
: "border-muted-foreground/30 hover:border-primary",
|
"hover:bg-muted/20 transition-colors",
|
||||||
)}
|
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
|
||||||
disabled={completeHabitMutation.isPending}
|
)}
|
||||||
onClick={() => completeHabitMutation.mutate(habit.id)}
|
>
|
||||||
aria-label={doneToday ? habit.name + " (completed)" : "Complete " + habit.name}
|
<CardContent className="p-2.5">
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
{doneToday && <Check className="h-3 w-3 text-white" />}
|
<button
|
||||||
</button>
|
className={cn(
|
||||||
<div
|
"w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors",
|
||||||
className="flex-1 min-w-0 cursor-pointer"
|
doneToday
|
||||||
onClick={() => navigate({ to: "/habits/$id", params: { id: habit.id } })}
|
? "bg-green-500 border-green-500"
|
||||||
>
|
: "border-muted-foreground/30 hover:border-primary",
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
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>
|
</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" />
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
{/* Recent notes */}
|
{/* Recent notes */}
|
||||||
<section>
|
{notes.length > 0 && (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<section>
|
||||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<h2 className="text-sm font-semibold text-blue-500">Recent Notes</h2>
|
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||||
</div>
|
<h2 className="text-sm font-semibold text-blue-500">Recent Notes</h2>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{notes.map((note) => (
|
{notes.map((note) => {
|
||||||
<Card
|
const idx = flatIndex("note", note.id);
|
||||||
key={note.id}
|
return (
|
||||||
className="hover:bg-muted/20 transition-colors cursor-pointer"
|
<div
|
||||||
onClick={() => navigate({ to: "/notes/$id", params: { id: note.id } })}
|
key={note.id}
|
||||||
>
|
ref={(el) => {
|
||||||
<CardContent className="p-2.5">
|
if (el) rowRefs.current.set(idx, el);
|
||||||
<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">
|
<Card
|
||||||
<p className="text-sm font-medium truncate">{note.title}</p>
|
className={cn(
|
||||||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
|
"hover:bg-muted/20 transition-colors cursor-pointer",
|
||||||
{format(parseISO(note.updatedAt), "MMM d, HH:mm")}
|
selectedRowIndex === idx && "ring-2 ring-primary/50 bg-muted/30",
|
||||||
</p>
|
)}
|
||||||
</div>
|
onClick={() =>
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
navigate({ to: "/notes/$id", params: { id: note.id } })
|
||||||
</div>
|
}
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { showUndoToast } from "@/lib/undo/use-undo-toast";
|
||||||
import type { Note, PaginatedResponse } from "@/lib/types";
|
import type { Note, PaginatedResponse } from "@/lib/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
@@ -218,7 +219,17 @@ function NotesPage() {
|
|||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.delete("/notes/" + id),
|
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"] });
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||||
selectedNoteRef.current = null;
|
selectedNoteRef.current = null;
|
||||||
setSelectedNoteId(null);
|
setSelectedNoteId(null);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ 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";
|
import { BulkActionBar } from "@/components/tasks/bulk-action-bar";
|
||||||
|
import { showUndoToast } from "@/lib/undo/use-undo-toast";
|
||||||
|
|
||||||
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" },
|
||||||
@@ -514,7 +515,23 @@ function TasksPage() {
|
|||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.delete("/tasks/" + id),
|
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"] });
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
setPanelOpen(false);
|
setPanelOpen(false);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/home/user/projects/dev/ProjectE/node_modules
|
|
||||||
Reference in New Issue
Block a user