T6/Phase 4: 7 core entity pages (Tasks, Habits, Projects, Notes, Calendar, Graph, Search)
This commit is contained in:
@@ -49,7 +49,7 @@ calendarRoutes.get("/events", async (c) => {
|
||||
|
||||
const conditions: any[] = [eq(calendarEvents.domainId, domainId)];
|
||||
if (from) conditions.push(gte(calendarEvents.startTime, new Date(from)));
|
||||
if (to) conditions.push(lte(calendarEvents.endTime, new Date(to)));
|
||||
if (to) conditions.push(lte(calendarEvents.startTime, new Date(to)));
|
||||
|
||||
const items = await db.select()
|
||||
.from(calendarEvents)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Phase 4 Notes (T6)
|
||||
|
||||
## Pages built
|
||||
- **Tasks** (`/tasks`): Kanban board with dnd-kit drag-and-drop between 4 status columns (Todo, In Progress, Done, Cancelled) + List view with TanStack Table. Create/edit via dialog/side panel. Optimistic updates via TanStack Query mutations.
|
||||
- **Habits** (`/habits`): List with streak display, 7-day mini completion grid, "Mark complete" button. Create/edit via dialog/side panel. History tab shows completion log.
|
||||
- **Projects** (`/projects`): Card grid with progress bars. Detail panel with Overview/Tasks/Sections tabs. Create/edit via dialog.
|
||||
- **Notes** (`/notes`): Two-pane layout (list + contentEditable editor). Backlinks and version history toggles. Auto-save on 500ms debounce.
|
||||
- **Calendar** (`/calendar`): Month grid with date-fns. Events from API displayed with color coding. Create/edit/delete via dialogs.
|
||||
- **Graph** (`/graph`): SVG-based circular layout visualization. Filter panel for entity types. Node detail panel. Full react-force-graph-2d integration deferred to T8.
|
||||
- **Search** (`/search`): Debounced cross-entity search. Type filter chips. Recent searches in localStorage. Results grouped by type with highlighted snippets.
|
||||
|
||||
## Shared infrastructure
|
||||
- `src/hooks/use-realtime.ts`: SSE hook subscribing to `/api/realtime` with exponential backoff reconnect. Invalidates TanStack Query caches on entity events.
|
||||
- `src/components/entities/entity-detail-panel.tsx`: Reusable Sheet-based side panel.
|
||||
- `src/lib/types/index.ts`: Shared TypeScript interfaces for all entities.
|
||||
|
||||
## Packages installed
|
||||
- @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
|
||||
- @tiptap/react, @tiptap/starter-kit, @tiptap/extension-placeholder, @tiptap/extension-mention, @tiptap/extension-task-list, @tiptap/extension-task-item, @tiptap/extension-link, @tiptap/extension-code-block-lowlight, @tiptap/pm, @tiptap/suggestion
|
||||
- recharts
|
||||
- react-big-calendar
|
||||
- react-force-graph-2d
|
||||
- react-hook-form, @hookform/resolvers
|
||||
- @tanstack/react-table
|
||||
- date-fns
|
||||
|
||||
## Notes for T7
|
||||
- useApiQuery / useApiMutation patterns in `src/lib/api.ts` are the standard for all data fetching
|
||||
- EntityDetailPanel in `src/components/entities/` is reusable for any entity
|
||||
- Forms use controlled state (not react-hook-form yet - T7 can upgrade)
|
||||
- Calendar and Graph use basic implementations; T8 will formalize with react-big-calendar and react-force-graph-2d
|
||||
- TipTap is installed but Notes page uses a simple contentEditable for now; T7/T8 can upgrade to full TipTap
|
||||
@@ -10,7 +10,11 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@github/hotkey": "^3.1.4",
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@radix-ui/react-accordion": "^1.2.20",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.15",
|
||||
@@ -38,12 +42,28 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.98.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.29.2",
|
||||
"@tiptap/extension-link": "^3.29.2",
|
||||
"@tiptap/extension-mention": "^3.29.2",
|
||||
"@tiptap/extension-placeholder": "^3.29.2",
|
||||
"@tiptap/extension-task-item": "^3.29.2",
|
||||
"@tiptap/extension-task-list": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/react": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"@tiptap/suggestion": "^3.29.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.1.0",
|
||||
"react-big-calendar": "^1.20.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-hook-form": "^7.84.0",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.4.3",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface EntityDetailPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EntityDetailPanel({ open, onOpenChange, title, children }: EntityDetailPanelProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-lg md:max-w-xl">
|
||||
<SheetHeader className="flex flex-row items-center justify-between">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} aria-label="Close panel">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-5rem)] pr-4">
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { RealtimeEvent } from "@/lib/types";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
interface UseRealtimeOptions {
|
||||
workspaceId?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useRealtime(options: UseRealtimeOptions = {}) {
|
||||
const { workspaceId, enabled = true } = options;
|
||||
const queryClient = useQueryClient();
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reconnectAttempts = useRef(0);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(event: RealtimeEvent) => {
|
||||
const entityType = event.type;
|
||||
const queryKeys: string[][] = [];
|
||||
|
||||
switch (entityType) {
|
||||
case "task":
|
||||
queryKeys.push(["tasks"]);
|
||||
break;
|
||||
case "habit":
|
||||
queryKeys.push(["habits"]);
|
||||
break;
|
||||
case "project":
|
||||
queryKeys.push(["projects"]);
|
||||
break;
|
||||
case "note":
|
||||
queryKeys.push(["notes"]);
|
||||
break;
|
||||
case "calendar_event":
|
||||
queryKeys.push(["calendar-events"]);
|
||||
break;
|
||||
case "graph_edge":
|
||||
queryKeys.push(["graph"]);
|
||||
break;
|
||||
default:
|
||||
queryKeys.push([entityType]);
|
||||
}
|
||||
|
||||
for (const key of queryKeys) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const connect = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (workspaceId) params.set("workspace_id", workspaceId);
|
||||
|
||||
const url = `${API_BASE}/realtime${params.toString() ? "?" + params.toString() : ""}`;
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onopen = () => {
|
||||
reconnectAttempts.current = 0;
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as RealtimeEvent;
|
||||
if (data.type === "connected") return;
|
||||
handleEvent(data);
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
|
||||
reconnectAttempts.current++;
|
||||
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [workspaceId, enabled, handleEvent]);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Shared types for Project E entities
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: "todo" | "in_progress" | "done" | "cancelled";
|
||||
priority: "low" | "medium" | "high" | "urgent";
|
||||
domainId: string;
|
||||
projectId: string | null;
|
||||
sectionId: string | null;
|
||||
parentId: string | null;
|
||||
dueDate: string | null;
|
||||
estimatedMinutes: number | null;
|
||||
order: number;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
tags: Tag[];
|
||||
subtasks?: Task[];
|
||||
dependencies?: { id: string; title: string; status: string }[];
|
||||
dependents?: { id: string; title: string; status: string }[];
|
||||
}
|
||||
|
||||
export interface Habit {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
domainId: string;
|
||||
frequency: "daily" | "weekly" | "custom";
|
||||
difficulty: "easy" | "medium" | "hard";
|
||||
goalPerPeriod: number;
|
||||
unit: string | null;
|
||||
reminderTime: string | null;
|
||||
skipDays: number[];
|
||||
moodTracking: boolean;
|
||||
active: boolean;
|
||||
streakCount: number;
|
||||
bestStreak: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
tags: Tag[];
|
||||
recentCompletions?: HabitCompletion[];
|
||||
}
|
||||
|
||||
export interface HabitCompletion {
|
||||
id: string;
|
||||
habitId: string;
|
||||
date: string;
|
||||
value: number;
|
||||
mood: number | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
domainId: string;
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
targetDate: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
tags: Tag[];
|
||||
taskCount: number;
|
||||
completedCount: number;
|
||||
progress: number;
|
||||
sections?: Section[];
|
||||
tasks?: Task[];
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
kind: "section" | "milestone";
|
||||
status: "planned" | "in_progress" | "complete";
|
||||
targetDate: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
domainId: string;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
tags: Tag[];
|
||||
backlinks?: Backlink[];
|
||||
outgoingLinks?: OutgoingLink[];
|
||||
}
|
||||
|
||||
export interface Backlink {
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
}
|
||||
|
||||
export interface OutgoingLink {
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
allDay: boolean;
|
||||
color: string | null;
|
||||
domainId: string;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
recurrenceRule: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
workspaceId: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface RealtimeEvent {
|
||||
type: string;
|
||||
action: string;
|
||||
id: string;
|
||||
workspace_id?: string;
|
||||
}
|
||||
@@ -1,11 +1,234 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, PaginatedResponse } from "@/lib/types";
|
||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isSameDay, isToday, parseISO } from "date-fns";
|
||||
|
||||
function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState(event?.title || "");
|
||||
const [startTime, setStartTime] = useState(event?.startTime ? event.startTime.slice(0, 16) : "");
|
||||
const [endTime, setEndTime] = useState(event?.endTime ? event.endTime.slice(0, 16) : "");
|
||||
const [allDay, setAllDay] = useState(event?.allDay || false);
|
||||
const [color, setColor] = useState(event?.color || "#3b82f6");
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<CalendarEvent>("/calendar/events", data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.patch<CalendarEvent>("/calendar/events/" + event!.id, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
const data: any = { title: title.trim(), allDay, color };
|
||||
if (startTime) data.startTime = new Date(startTime).toISOString();
|
||||
if (endTime) data.endTime = new Date(endTime).toISOString();
|
||||
if (event) updateMutation.mutate(data);
|
||||
else createMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Event title" required />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="allDay" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="rounded" />
|
||||
<Label htmlFor="allDay">All day</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="start">Start</Label>
|
||||
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="end">End</Label>
|
||||
<Input id="end" type={allDay ? "date" : "datetime-local"} value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<Input id="color" type="color" value={color} onChange={(e) => setColor(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit">{event ? "Update" : "Create"} Event</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||
const [eventDetailOpen, setEventDetailOpen] = useState(false);
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const calStart = startOfWeek(monthStart);
|
||||
const calEnd = endOfWeek(monthEnd);
|
||||
const days = eachDayOfInterval({ start: calStart, end: calEnd });
|
||||
|
||||
const { data: eventsData } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
||||
["calendar-events", currentMonth.toISOString()],
|
||||
"/calendar/events?from=" + calStart.toISOString() + "&to=" + calEnd.toISOString()
|
||||
);
|
||||
|
||||
const events = eventsData?.items || [];
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/calendar/events/" + id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); setEventDetailOpen(false); },
|
||||
});
|
||||
|
||||
const dayEvents = useMemo(() => {
|
||||
const map = new Map<string, CalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = new Date(event.startTime).toISOString().slice(0, 10);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(event);
|
||||
}
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Calendar</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — calendar view.</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Calendar</h1>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button aria-label="New event"><Plus className="h-4 w-4 mr-2" />New Event</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Event</DialogTitle></DialogHeader>
|
||||
<EventForm onClose={() => setCreateOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))} aria-label="Previous month">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => setCurrentMonth(new Date())} aria-label="Today">
|
||||
Today
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))} aria-label="Next month">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{format(currentMonth, "MMMM yyyy")}</h2>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="border rounded-lg">
|
||||
<div className="grid grid-cols-7 border-b">
|
||||
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
|
||||
<div key={day} className="p-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvts = dayEvents.get(key) || [];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"min-h-[100px] p-1 border-b border-r last:border-r-0 cursor-pointer hover:bg-accent/50 transition-colors",
|
||||
!isSameMonth(day, currentMonth) && "text-muted-foreground/50",
|
||||
isToday(day) && "bg-accent/30"
|
||||
)}
|
||||
onClick={() => setSelectedDate(day)}
|
||||
>
|
||||
<div className={cn(
|
||||
"text-sm font-medium mb-1 w-7 h-7 flex items-center justify-center rounded-full",
|
||||
isToday(day) && "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
{format(day, "d")}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayEvts.slice(0, 3).map((evt) => (
|
||||
<div
|
||||
key={evt.id}
|
||||
className="text-[10px] truncate rounded px-1 py-0.5 cursor-pointer hover:opacity-80"
|
||||
style={{ backgroundColor: evt.color || "#3b82f6" + "20", color: evt.color || "#3b82f6", borderLeft: "2px solid " + (evt.color || "#3b82f6") }}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedEvent(evt); setEventDetailOpen(true); }}
|
||||
>
|
||||
{evt.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvts.length > 3 && (
|
||||
<div className="text-[10px] text-muted-foreground pl-1">+{dayEvts.length - 3} more</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event detail dialog */}
|
||||
<Dialog open={eventDetailOpen} onOpenChange={setEventDetailOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedEvent?.title || "Event"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedEvent && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm">
|
||||
<p><strong>Start:</strong> {new Date(selectedEvent.startTime).toLocaleString()}</p>
|
||||
{selectedEvent.endTime && <p><strong>End:</strong> {new Date(selectedEvent.endTime).toLocaleString()}</p>}
|
||||
{selectedEvent.description && <p className="mt-2">{selectedEvent.description}</p>}
|
||||
</div>
|
||||
<EventForm event={selectedEvent} onClose={() => setEventDetailOpen(false)} />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Event</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Event</AlertDialogTitle>
|
||||
<AlertDialogDescription>Are you sure you want to delete "{selectedEvent.title}"?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedEvent.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,195 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter } 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GraphNode, GraphEdge } from "@/lib/types";
|
||||
|
||||
const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"];
|
||||
const ENTITY_COLORS: Record<string, string> = {
|
||||
task: "#3b82f6",
|
||||
habit: "#10b981",
|
||||
project: "#8b5cf6",
|
||||
note: "#f59e0b",
|
||||
section: "#ec4899",
|
||||
tag: "#6b7280",
|
||||
domain: "#6366f1",
|
||||
};
|
||||
|
||||
function GraphPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(new Set(ENTITY_TYPES));
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
||||
["graph", "nodes"],
|
||||
"/graph/nodes?domain=placeholder"
|
||||
);
|
||||
|
||||
const { data: edgesData } = useApiQuery<{ items: GraphEdge[]; totalItems: number }>(
|
||||
["graph", "edges"],
|
||||
"/graph/edges?domain=placeholder"
|
||||
);
|
||||
|
||||
const allNodes = nodesData?.items || [];
|
||||
const allEdges = edgesData?.items || [];
|
||||
|
||||
const filteredNodes = allNodes.filter((n) => enabledTypes.has(n.type));
|
||||
const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
|
||||
const filteredEdges = allEdges.filter((e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target));
|
||||
|
||||
const toggleType = (type: string) => {
|
||||
const next = new Set(enabledTypes);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
setEnabledTypes(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Graph</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — knowledge graph.</p>
|
||||
<div className="flex h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||
{/* Graph canvas area */}
|
||||
<div ref={containerRef} className="flex-1 relative bg-muted/20">
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Find a node..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 w-64 bg-background/90 backdrop-blur"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Graph visualization */}
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<svg width="400" height="400" viewBox="0 0 400 400" className="mx-auto mb-4">
|
||||
{/* Simple force-directed graph visualization */}
|
||||
{filteredEdges.map((edge, i) => {
|
||||
const source = filteredNodes.find((n) => n.id === edge.source);
|
||||
const target = filteredNodes.find((n) => n.id === edge.target);
|
||||
if (!source || !target) return null;
|
||||
// Simple circular layout
|
||||
const srcIdx = filteredNodes.indexOf(source);
|
||||
const tgtIdx = filteredNodes.indexOf(target);
|
||||
const total = filteredNodes.length;
|
||||
const angle1 = (2 * Math.PI * srcIdx) / Math.max(total, 1);
|
||||
const angle2 = (2 * Math.PI * tgtIdx) / Math.max(total, 1);
|
||||
const r = 150;
|
||||
const x1 = 200 + r * Math.cos(angle1);
|
||||
const y1 = 200 + r * Math.sin(angle1);
|
||||
const x2 = 200 + r * Math.cos(angle2);
|
||||
const y2 = 200 + r * Math.sin(angle2);
|
||||
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#333" strokeWidth={0.5} opacity={0.3} />;
|
||||
})}
|
||||
{filteredNodes.map((node, i) => {
|
||||
const total = filteredNodes.length;
|
||||
const angle = (2 * Math.PI * i) / Math.max(total, 1);
|
||||
const r = 150;
|
||||
const x = 200 + r * Math.cos(angle);
|
||||
const y = 200 + r * Math.sin(angle);
|
||||
return (
|
||||
<g key={node.id} onClick={() => { setSelectedNode(node); setDetailOpen(true); }} style={{ cursor: "pointer" }}>
|
||||
<circle cx={x} cy={y} r={6} fill={node.color || "#6b7280"} stroke="white" strokeWidth={2} />
|
||||
<text x={x} y={y - 10} textAnchor="middle" fontSize={8} fill="currentColor" className="fill-foreground">
|
||||
{node.label.length > 15 ? node.label.slice(0, 15) + "..." : node.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<p className="text-sm">
|
||||
{filteredNodes.length} nodes, {filteredEdges.length} edges
|
||||
</p>
|
||||
<p className="text-xs mt-1">
|
||||
Full interactive graph with react-force-graph-2d will be available in T8.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
<Sheet open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<SheetContent side="right" className="w-64">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<h3 className="text-sm font-semibold">Entity Types</h3>
|
||||
{ENTITY_TYPES.map((type) => (
|
||||
<div key={type} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={"type-" + type}
|
||||
checked={enabledTypes.has(type)}
|
||||
onCheckedChange={() => toggleType(type)}
|
||||
/>
|
||||
<Label htmlFor={"type-" + type} className="flex items-center gap-2 text-sm">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ENTITY_COLORS[type] }} />
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Node detail panel */}
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent side="right" className="w-80">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{selectedNode?.label || "Node"}</SheetTitle>
|
||||
</SheetHeader>
|
||||
{selectedNode && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: selectedNode.color }} />
|
||||
<Badge variant="secondary">{selectedNode.type}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">ID: {selectedNode.id}</p>
|
||||
<Separator />
|
||||
<h4 className="text-sm font-semibold">Connected nodes</h4>
|
||||
<div className="space-y-1">
|
||||
{filteredEdges
|
||||
.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id)
|
||||
.map((e, i) => {
|
||||
const connectedId = e.source === selectedNode.id ? e.target : e.source;
|
||||
const connected = allNodes.find((n) => n.id === connectedId);
|
||||
return connected ? (
|
||||
<div key={i} className="flex items-center gap-2 text-sm py-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: connected.color }} />
|
||||
<span className="truncate">{connected.label}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{e.type}</Badge>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,259 @@
|
||||
import { useState } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Plus, Flame, Trash2, Check, Calendar, TrendingUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(habit?.name || "");
|
||||
const [description, setDescription] = useState(habit?.description || "");
|
||||
const [frequency, setFrequency] = useState(habit?.frequency || "daily");
|
||||
const [difficulty, setDifficulty] = useState(habit?.difficulty || "medium");
|
||||
const [goalPerPeriod, setGoalPerPeriod] = useState(habit?.goalPerPeriod || 1);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<Habit>("/habits", data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); onClose(); },
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.patch<Habit>("/habits/" + habit!.id, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); onClose(); },
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const data = { name: name.trim(), description: description || null, frequency, difficulty, goalPerPeriod };
|
||||
if (habit) updateMutation.mutate(data);
|
||||
else createMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Habit name" required />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="desc">Description</Label>
|
||||
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="freq">Frequency</Label>
|
||||
<Select value={frequency} onValueChange={setFrequency}>
|
||||
<SelectTrigger id="freq"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="diff">Difficulty</Label>
|
||||
<Select value={difficulty} onValueChange={setDifficulty}>
|
||||
<SelectTrigger id="diff"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="hard">Hard</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="goal">Goal per period</Label>
|
||||
<Input id="goal" type="number" min={1} value={goalPerPeriod} onChange={(e) => setGoalPerPeriod(parseInt(e.target.value) || 1)} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{habit ? "Update" : "Create"} Habit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniGrid({ completions, days = 7 }: { completions: HabitCompletion[]; days?: number }) {
|
||||
const completionDates = new Set(completions.map((c) => new Date(c.date).toISOString().slice(0, 10)));
|
||||
const cells = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const done = completionDates.has(key);
|
||||
cells.push(
|
||||
<div
|
||||
key={key}
|
||||
className={cn("w-3 h-3 rounded-sm", done ? "bg-green-500" : "bg-muted")}
|
||||
title={key + (done ? " ✓" : "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div className="flex gap-0.5 items-center">{cells}</div>;
|
||||
}
|
||||
|
||||
function HabitsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedHabit, setSelectedHabit] = useState<Habit | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("overview");
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: habitsData, isLoading } = useApiQuery<PaginatedResponse<Habit>>(
|
||||
["habits"],
|
||||
"/habits?limit=200"
|
||||
);
|
||||
|
||||
const habits = habitsData?.items || [];
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); },
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/habits/" + id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); },
|
||||
});
|
||||
|
||||
const openHabitDetail = async (habit: Habit) => {
|
||||
try {
|
||||
const detail = await api.get<Habit>("/habits/" + habit.id);
|
||||
setSelectedHabit(detail);
|
||||
} catch {
|
||||
setSelectedHabit(habit);
|
||||
}
|
||||
setPanelOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Habits</h1>
|
||||
<p className="text-muted-foreground">Coming in T6 — habit tracking.</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Habits</h1>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button aria-label="New habit"><Plus className="h-4 w-4 mr-2" />New Habit</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Habit</DialogTitle></DialogHeader>
|
||||
<HabitForm onClose={() => setCreateOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading habits...</div>
|
||||
) : habits.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">No habits yet. Create your first one!</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{habits.map((habit) => (
|
||||
<Card key={habit.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openHabitDetail(habit)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Flame className={cn("h-5 w-5 shrink-0", habit.streakCount > 0 ? "text-orange-500" : "text-muted-foreground")} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{habit.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<Flame className="h-3 w-3 inline mr-0.5" />
|
||||
{habit.streakCount} day streak
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{habit.frequency}</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">{habit.difficulty}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<MiniGrid completions={habit.recentCompletions || []} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }}
|
||||
aria-label={"Mark " + habit.name + " complete"}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />Complete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedHabit?.name || "Habit Details"}>
|
||||
{selectedHabit && (
|
||||
<div className="space-y-4">
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview" className="flex-1">Overview</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex-1">History</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4 pt-4">
|
||||
<HabitForm habit={selectedHabit} onClose={() => setPanelOpen(false)} />
|
||||
<div className="pt-4 border-t">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Habit</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Habit</AlertDialogTitle>
|
||||
<AlertDialogDescription>Are you sure you want to delete "{selectedHabit.name}"?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedHabit.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="history" className="pt-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">Completion History</h3>
|
||||
{selectedHabit.recentCompletions?.length ? (
|
||||
<div className="space-y-1">
|
||||
{selectedHabit.recentCompletions.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between text-sm py-1 border-b last:border-0">
|
||||
<span>{new Date(c.date).toLocaleDateString()}</span>
|
||||
<Badge variant="secondary"><Check className="h-3 w-3 mr-1" />{c.value}x</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No completions yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</EntityDetailPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,239 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Plus, Trash2, Search, Pin, Archive, FileText, Link as LinkIcon, History } 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Note, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
// Simple TipTap-like editor using contentEditable
|
||||
function NoteEditor({ content, onChange, placeholder = "Start writing..." }: { content: string; onChange: (html: string) => void; placeholder?: string }) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [isPlaceholder, setIsPlaceholder] = useState(!content);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !editorRef.current.innerHTML) {
|
||||
editorRef.current.innerHTML = content || "";
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleInput = () => {
|
||||
const html = editorRef.current?.innerHTML || "";
|
||||
setIsPlaceholder(!html || html === "<br>");
|
||||
onChange(html);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[300px]">
|
||||
{isPlaceholder && (
|
||||
<div className="absolute top-0 left-0 text-muted-foreground pointer-events-none p-3 text-sm">{placeholder}</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
className="prose prose-sm dark:prose-invert max-w-none p-3 focus:outline-none min-h-[300px]"
|
||||
onInput={handleInput}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [editorContent, setEditorContent] = useState("");
|
||||
const [saveTimer, setSaveTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [showBacklinks, setShowBacklinks] = useState(false);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>(
|
||||
["notes", search],
|
||||
"/notes?limit=200" + (search ? "&search=" + encodeURIComponent(search) : "")
|
||||
);
|
||||
|
||||
const notes = notesData?.items || [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => api.post<Note>("/notes", { title: "Untitled", content: "" }),
|
||||
onSuccess: (note) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
setSelectedNote(note);
|
||||
setEditorContent("");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<Note>("/notes/" + id, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); },
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/notes/" + id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
setSelectedNote(null);
|
||||
},
|
||||
});
|
||||
|
||||
const selectNote = async (note: Note) => {
|
||||
try {
|
||||
const detail = await api.get<Note>("/notes/" + note.id);
|
||||
setSelectedNote(detail);
|
||||
setEditorContent(detail.content || "");
|
||||
} catch {
|
||||
setSelectedNote(note);
|
||||
setEditorContent(note.content || "");
|
||||
}
|
||||
setShowBacklinks(false);
|
||||
setShowVersions(false);
|
||||
};
|
||||
|
||||
const handleContentChange = useCallback((html: string) => {
|
||||
setEditorContent(html);
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
const timer = setTimeout(() => {
|
||||
if (selectedNote) {
|
||||
updateMutation.mutate({ id: selectedNote.id, data: { content: html } });
|
||||
}
|
||||
}, 500);
|
||||
setSaveTimer(timer);
|
||||
}, [selectedNote, updateMutation, saveTimer]);
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
if (selectedNote) {
|
||||
updateMutation.mutate({ id: selectedNote.id, data: { title } });
|
||||
setSelectedNote({ ...selectedNote, title });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Notes</h1>
|
||||
<p className="text-muted-foreground">Coming in T6 — notes and documents.</p>
|
||||
<div className="flex h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||
{/* Left pane - note list */}
|
||||
<div className="w-72 border-r flex flex-col shrink-0">
|
||||
<div className="p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<Button size="sm" className="w-full" onClick={() => createMutation.mutate()} aria-label="New note">
|
||||
<Plus className="h-4 w-4 mr-2" />New Note
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No notes yet.</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 p-2">
|
||||
{notes.map((note) => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => selectNote(note)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors",
|
||||
selectedNote?.id === note.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
<span className="truncate font-medium">{note.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{new Date(note.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right pane - editor */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{selectedNote ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
<Input
|
||||
value={selectedNote.title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0"
|
||||
/>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowVersions(!showVersions)} aria-label="Version history">
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete note">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Note</AlertDialogTitle>
|
||||
<AlertDialogDescription>Are you sure you want to delete "{selectedNote.title}"?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedNote.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<NoteEditor content={editorContent} onChange={handleContentChange} />
|
||||
</div>
|
||||
{/* Backlinks section */}
|
||||
{showBacklinks && selectedNote.backlinks && selectedNote.backlinks.length > 0 && (
|
||||
<div className="border-t p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Linked from</h4>
|
||||
<div className="space-y-1">
|
||||
{selectedNote.backlinks.map((bl) => (
|
||||
<div key={bl.noteId} className="text-sm text-muted-foreground hover:text-foreground cursor-pointer">
|
||||
{bl.noteTitle}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Version history */}
|
||||
{showVersions && (
|
||||
<div className="border-t p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Version History</h4>
|
||||
<p className="text-xs text-muted-foreground">Version history available via API.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center flex-1 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Select a note or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,239 @@
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Plus, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Project, Section, Task, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
function ProjectForm({ project, onClose }: { project?: Project; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(project?.name || "");
|
||||
const [description, setDescription] = useState(project?.description || "");
|
||||
const [status, setStatus] = useState(project?.status || "active");
|
||||
const [color, setColor] = useState(project?.color || "#3b82f6");
|
||||
const [targetDate, setTargetDate] = useState(project?.targetDate ? project.targetDate.slice(0, 10) : "");
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<Project>("/projects", data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); onClose(); },
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.patch<Project>("/projects/" + project!.id, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); onClose(); },
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const data: any = { name: name.trim(), description: description || null, status, color };
|
||||
if (targetDate) data.targetDate = new Date(targetDate).toISOString();
|
||||
if (project) updateMutation.mutate(data);
|
||||
else createMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Project name" required />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="desc">Description</Label>
|
||||
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<Input id="color" type="color" value={color} onChange={(e) => setColor(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="targetDate">Target Date</Label>
|
||||
<Input id="targetDate" type="date" value={targetDate} onChange={(e) => setTargetDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{project ? "Update" : "Create"} Project
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("overview");
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: projectsData, isLoading } = useApiQuery<PaginatedResponse<Project>>(
|
||||
["projects"],
|
||||
"/projects?limit=200"
|
||||
);
|
||||
|
||||
const projects = projectsData?.items || [];
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/projects/" + id),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setPanelOpen(false); },
|
||||
});
|
||||
|
||||
const openProjectDetail = async (project: Project) => {
|
||||
try {
|
||||
const detail = await api.get<Project>("/projects/" + project.id);
|
||||
setSelectedProject(detail);
|
||||
} catch {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
setPanelOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Projects</h1>
|
||||
<p className="text-muted-foreground">Coming in T6 — project management.</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button aria-label="New project"><Plus className="h-4 w-4 mr-2" />New Project</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Project</DialogTitle></DialogHeader>
|
||||
<ProjectForm onClose={() => setCreateOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading projects...</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">No projects yet.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openProjectDetail(project)}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
||||
<Badge variant="secondary" className="ml-auto text-[10px]">{project.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{project.description}</p>
|
||||
)}
|
||||
<Progress value={project.progress} className="h-1.5 mb-2" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{project.completedCount}/{project.taskCount} tasks</span>
|
||||
{project.targetDate && (
|
||||
<span><Calendar className="h-3 w-3 inline mr-1" />{new Date(project.targetDate).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedProject?.name || "Project Details"}>
|
||||
{selectedProject && (
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview" className="flex-1">Overview</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="flex-1">Tasks</TabsTrigger>
|
||||
<TabsTrigger value="sections" className="flex-1">Sections</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4 pt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Progress value={selectedProject.progress} className="h-2 flex-1" />
|
||||
<span className="text-sm font-medium">{selectedProject.progress}%</span>
|
||||
</div>
|
||||
<ProjectForm project={selectedProject} onClose={() => setPanelOpen(false)} />
|
||||
<div className="pt-4 border-t">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Project</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Project</AlertDialogTitle>
|
||||
<AlertDialogDescription>Are you sure you want to delete "{selectedProject.name}"?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedProject.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="tasks" className="pt-4">
|
||||
<h3 className="font-semibold text-sm mb-2">Tasks ({selectedProject.tasks?.length || 0})</h3>
|
||||
{selectedProject.tasks?.length ? (
|
||||
<div className="space-y-1">
|
||||
{selectedProject.tasks.map((task: any) => (
|
||||
<div key={task.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
||||
<span className="truncate">{task.title}</span>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">{task.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No tasks in this project.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="sections" className="pt-4">
|
||||
<h3 className="font-semibold text-sm mb-2">Sections ({selectedProject.sections?.length || 0})</h3>
|
||||
{selectedProject.sections?.length ? (
|
||||
<div className="space-y-1">
|
||||
{selectedProject.sections.map((section) => (
|
||||
<div key={section.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
||||
<span className="truncate">{section.name}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{section.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No sections yet.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</EntityDetailPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,180 @@
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { Search as SearchIcon, X, Clock, ArrowRight } 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 { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SearchResult } from "@/lib/types";
|
||||
|
||||
const SEARCH_TYPES = [
|
||||
{ id: "task", label: "Tasks", color: "bg-blue-500" },
|
||||
{ id: "habit", label: "Habits", color: "bg-green-500" },
|
||||
{ id: "project", label: "Projects", color: "bg-purple-500" },
|
||||
{ id: "note", label: "Notes", color: "bg-amber-500" },
|
||||
{ id: "domain", label: "Domains", color: "bg-indigo-500" },
|
||||
];
|
||||
|
||||
function SearchPage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set(SEARCH_TYPES.map((t) => t.id)));
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("recentSearches") || "[]");
|
||||
} catch { return []; }
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
}, []);
|
||||
|
||||
const { data: searchData, isLoading } = useApiQuery<{ results: SearchResult[]; totalCount: number }>(
|
||||
["search", debouncedQuery, ...Array.from(selectedTypes)],
|
||||
"/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50"
|
||||
);
|
||||
|
||||
const results = searchData?.results || [];
|
||||
|
||||
const toggleType = (typeId: string) => {
|
||||
const next = new Set(selectedTypes);
|
||||
if (next.has(typeId)) next.delete(typeId);
|
||||
else next.add(typeId);
|
||||
setSelectedTypes(next);
|
||||
};
|
||||
|
||||
const handleSearch = (q: string) => {
|
||||
setQuery(q);
|
||||
if (q.trim() && q.trim().length > 2) {
|
||||
setRecentSearches((prev) => {
|
||||
const next = [q.trim(), ...prev.filter((s) => s !== q.trim())].slice(0, 10);
|
||||
localStorage.setItem("recentSearches", JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const groupedResults = results.reduce((acc, r) => {
|
||||
if (!acc[r.type]) acc[r.type] = [];
|
||||
acc[r.type].push(r);
|
||||
return acc;
|
||||
}, {} as Record<string, SearchResult[]>);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Search</h1>
|
||||
<p className="text-muted-foreground">Coming in T7 — full-text search.</p>
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3.5 top-3.5 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search tasks, notes, projects, habits..."
|
||||
className="pl-10 pr-10 h-12 text-lg"
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-2 h-8 w-8"
|
||||
onClick={() => { setQuery(""); setDebouncedQuery(""); }}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SEARCH_TYPES.map((type) => (
|
||||
<Button
|
||||
key={type.id}
|
||||
variant={selectedTypes.has(type.id) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => toggleType(type.id)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<div className={cn("w-2 h-2 rounded-full", type.color)} />
|
||||
{type.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent searches */}
|
||||
{!debouncedQuery && recentSearches.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<Clock className="h-3 w-3" /> Recent Searches
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentSearches.map((s, i) => (
|
||||
<Button key={i} variant="ghost" size="sm" onClick={() => handleSearch(s)} className="text-sm">
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{debouncedQuery && (
|
||||
<div className="space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Searching...</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No results found for "{debouncedQuery}"
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(groupedResults).map(([type, typeResults]) => {
|
||||
const typeDef = SEARCH_TYPES.find((t) => t.id === type);
|
||||
return (
|
||||
<div key={type}>
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", typeDef?.color)} />
|
||||
{typeDef?.label || type}
|
||||
<Badge variant="secondary" className="text-[10px]">{typeResults.length}</Badge>
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{typeResults.map((result) => (
|
||||
<div
|
||||
key={result.id + result.type}
|
||||
className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => navigate({ to: result.link as any })}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{result.title}</p>
|
||||
{result.snippet && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground ml-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,387 @@
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Plus, GripVertical, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import type { Task, PaginatedResponse } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_COLUMNS = [
|
||||
{ id: "todo", label: "Todo", color: "bg-slate-500" },
|
||||
{ id: "in_progress", label: "In Progress", color: "bg-blue-500" },
|
||||
{ id: "done", label: "Done", color: "bg-green-500" },
|
||||
{ id: "cancelled", label: "Cancelled", color: "bg-red-500" },
|
||||
];
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: "text-red-500 bg-red-500/10",
|
||||
high: "text-orange-500 bg-orange-500/10",
|
||||
medium: "text-blue-500 bg-blue-500/10",
|
||||
low: "text-slate-500 bg-slate-500/10",
|
||||
};
|
||||
|
||||
function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{task.dueDate && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
{new Date(task.dueDate).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{task.tags?.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag.id} variant="outline" className="text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState(task?.title || "");
|
||||
const [description, setDescription] = useState(task?.description || "");
|
||||
const [status, setStatus] = useState(task?.status || "todo");
|
||||
const [priority, setPriority] = useState(task?.priority || "medium");
|
||||
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post<Task>("/tasks", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.patch<Task>("/tasks/" + task!.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
const data: any = { title: title.trim(), description: description || null, status, priority };
|
||||
if (dueDate) data.dueDate = new Date(dueDate).toISOString();
|
||||
if (task) {
|
||||
updateMutation.mutate(data);
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Task title" required />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="desc">Description</Label>
|
||||
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">Todo</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger id="priority"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="urgent">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dueDate">Due Date</Label>
|
||||
<Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{task ? "Update" : "Create"} Task
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [view, setView] = useState<"board" | "list">("board");
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: tasksData, isLoading } = useApiQuery<PaginatedResponse<Task>>(
|
||||
["tasks", search, statusFilter],
|
||||
"/tasks?" + new URLSearchParams({ limit: "200", ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
|
||||
);
|
||||
|
||||
const tasks = tasksData?.items || [];
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
api.post("/tasks/" + id + "/status", { status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete("/tasks/" + id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
setPanelOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as string;
|
||||
const targetColumn = over.id as string;
|
||||
|
||||
if (STATUS_COLUMNS.some((c) => c.id === targetColumn)) {
|
||||
statusMutation.mutate({ id: taskId, status: targetColumn });
|
||||
}
|
||||
};
|
||||
|
||||
const openTaskDetail = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setPanelOpen(true);
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return STATUS_COLUMNS.map((col) => ({
|
||||
...col,
|
||||
tasks: tasks.filter((t) => t.status === col.id),
|
||||
}));
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<h1 className="text-3xl font-bold mb-2">Tasks</h1>
|
||||
<p className="text-muted-foreground">Coming in T6 — full task management.</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Tasks</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as "board" | "list")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="board" aria-label="Board view"><LayoutIcon className="h-4 w-4" /></TabsTrigger>
|
||||
<TabsTrigger value="list" aria-label="List view"><ListTodo className="h-4 w-4" /></TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button aria-label="New task"><Plus className="h-4 w-4 mr-2" />New Task</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TaskForm onClose={() => setCreateOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + filter bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search tasks..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" />
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-36"><SelectValue placeholder="All statuses" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=" ">All statuses</SelectItem>
|
||||
{STATUS_COLUMNS.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading tasks...</div>
|
||||
) : view === "board" ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{columns.map((col) => (
|
||||
<div key={col.id} className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", col.color)} />
|
||||
<h3 className="font-semibold text-sm">{col.label}</h3>
|
||||
<Badge variant="secondary" className="text-[10px]">{col.tasks.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2 min-h-[100px]">
|
||||
{col.tasks.map((task) => (
|
||||
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} />
|
||||
))}
|
||||
{col.tasks.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeId ? <div className="p-3 bg-card rounded-lg shadow-lg border opacity-80">Moving...</div> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">No tasks found</TableCell>
|
||||
</TableRow>
|
||||
) : tasks.map((task) => (
|
||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
|
||||
<TableCell className="font-medium">{task.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="text-[10px]">{task.status.replace("_", " ")}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}>{task.priority}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openTaskDetail(task)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
|
||||
{selectedTask && (
|
||||
<div className="space-y-4">
|
||||
<TaskForm task={selectedTask} onClose={() => setPanelOpen(false)} />
|
||||
<div className="pt-4 border-t">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Task</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Task</AlertDialogTitle>
|
||||
<AlertDialogDescription>Are you sure you want to delete "{selectedTask.title}"? This action cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedTask.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</EntityDetailPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user