T6/Phase 4: 7 core entity pages (Tasks, Habits, Projects, Notes, Calendar, Graph, Search)

This commit is contained in:
Hermes
2026-08-01 02:10:18 +00:00
parent efcc748adb
commit f617c39937
14 changed files with 2082 additions and 26 deletions
+1 -1
View File
@@ -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)
+32
View File
@@ -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
+20
View File
@@ -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>
);
}
+98
View File
@@ -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]);
}
+175
View File
@@ -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;
}
+226 -3
View File
@@ -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>
);
}
+187 -3
View File
@@ -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>
);
}
+251 -3
View File
@@ -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>
);
}
+231 -3
View File
@@ -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>
);
}
+232 -4
View File
@@ -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>
);
}
+173 -4
View File
@@ -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>
);
}
+380 -4
View File
@@ -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>
);
}
+47 -1
View File
@@ -36,7 +36,11 @@
"name": "@project-e/web",
"version": "0.1.0",
"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",
@@ -64,12 +68,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",
@@ -798,12 +818,16 @@
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/router-core": ["@tanstack/router-core@1.171.15", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.168.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.170.0", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-wQoQhlBK7nlZgqzaqdYXKWNTpdHdsaREdaPhFZVH0/Ador+F+eM3/NF2i3f2LPeS0GgKraZUQXe1Q/1+KHyEYg=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tiptap/core": ["@tiptap/core@3.29.2", "", { "peerDependencies": { "@tiptap/pm": "3.29.2" } }, "sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw=="],
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg=="],
@@ -818,6 +842,8 @@
"@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ=="],
"@tiptap/extension-code-block-lowlight": ["@tiptap/extension-code-block-lowlight@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/extension-code-block": "3.29.2", "@tiptap/pm": "3.29.2", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "sha512-iUtg33uSlbnU0y6c3nUSWdw8ILmbuc+HeAouqZ7Vl4leYddLf7wLP4s+RJ8PKDJe8m1r6GTK6VVpCOUK5eIFzQ=="],
"@tiptap/extension-document": ["@tiptap/extension-document@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ=="],
"@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.29.2", "", { "peerDependencies": { "@tiptap/extensions": "3.29.2" } }, "sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA=="],
@@ -842,6 +868,8 @@
"@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA=="],
"@tiptap/extension-mention": ["@tiptap/extension-mention@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2", "@tiptap/suggestion": "3.29.2" } }, "sha512-WF8ugDa2IRbgyRW+PffPYg8ZI+Qp9azvIsNoyuf+VSg5Vp7ze2TuJXUTObSckyiN5Ann8bDNM9Hbu3TdoI0DFQ=="],
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg=="],
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ=="],
@@ -850,6 +878,10 @@
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg=="],
"@tiptap/extension-task-item": ["@tiptap/extension-task-item@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-mi5p2/FbUO9LFl7qPmRBThME+bFpd0vyeOD6IiWnD32hYZ720/N5VDkDypPmz+IaxTFaiLeGVmGC1SxGtxksKA=="],
"@tiptap/extension-task-list": ["@tiptap/extension-task-list@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-7ZkVoMcM3FAuvilZs3XliRRowPVMrVoKhfklfbNkTj9nVOvVToM+xcXKCcsZnf1mjMAyjw65m8doXJQKGCH7DA=="],
"@tiptap/extension-text": ["@tiptap/extension-text@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA=="],
@@ -862,6 +894,8 @@
"@tiptap/starter-kit": ["@tiptap/starter-kit@3.29.2", "", { "dependencies": { "@tiptap/core": "^3.29.2", "@tiptap/extension-blockquote": "^3.29.2", "@tiptap/extension-bold": "^3.29.2", "@tiptap/extension-bullet-list": "^3.29.2", "@tiptap/extension-code": "^3.29.2", "@tiptap/extension-code-block": "^3.29.2", "@tiptap/extension-document": "^3.29.2", "@tiptap/extension-dropcursor": "^3.29.2", "@tiptap/extension-gapcursor": "^3.29.2", "@tiptap/extension-hard-break": "^3.29.2", "@tiptap/extension-heading": "^3.29.2", "@tiptap/extension-horizontal-rule": "^3.29.2", "@tiptap/extension-italic": "^3.29.2", "@tiptap/extension-link": "^3.29.2", "@tiptap/extension-list": "^3.29.2", "@tiptap/extension-list-item": "^3.29.2", "@tiptap/extension-list-keymap": "^3.29.2", "@tiptap/extension-ordered-list": "^3.29.2", "@tiptap/extension-paragraph": "^3.29.2", "@tiptap/extension-strike": "^3.29.2", "@tiptap/extension-text": "^3.29.2", "@tiptap/extension-underline": "^3.29.2", "@tiptap/extensions": "^3.29.2", "@tiptap/pm": "^3.29.2" } }, "sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA=="],
"@tiptap/suggestion": ["@tiptap/suggestion@3.29.2", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-ZEhRm0gnRCwCScR9IrnIBhm9sr6U8vpR9oeznYk3hiedZ48zsgohqvgoIYpWcwYWrT2Pb0NrfhCT8IuSzdJHzQ=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-po+7rfJfUnFXjWlcoN2RwhErgzCdRtBc1T26vYPcywHlggmCQiQe1uWaE4j+BibI2uY9/2pDoFzMN0rmSaPFOw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+zB2btDJ00lnPRuqOvpVvgl4x34k/djZQGZTTCfjn7JgNCl8QFY5Njo5+dqkY1g/+9gbbsnAvWm9CmJg9ebcXA=="],
@@ -910,6 +944,8 @@
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
@@ -932,6 +968,8 @@
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/warning": ["@types/warning@3.0.4", "", {}, "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg=="],
@@ -1252,6 +1290,8 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
@@ -1468,6 +1508,8 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"hono": ["hono@4.12.33", "", {}, "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
@@ -1722,6 +1764,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="],
@@ -1950,7 +1994,7 @@
"react-grid-layout": ["react-grid-layout@2.2.4", "", { "dependencies": { "clsx": "^2.1.1", "fast-equals": "^4.0.3", "prop-types": "^15.8.1", "react-draggable": "^4.4.6", "react-resizable": "^3.1.3", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA=="],
"react-hook-form": ["react-hook-form@7.83.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="],
"react-hook-form": ["react-hook-form@7.84.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ=="],
"react-is": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="],
@@ -2286,6 +2330,8 @@
"@project-e/web-legacy/bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"@project-e/web-legacy/react-hook-form": ["react-hook-form@7.83.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="],
"@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],