T8/Phase 6: OSS component swap polish
- Calendar: react-big-calendar w/ withDragAndDrop, date-fns localizer, custom event renderer (color tokens), custom toolbar, responsive (agenda on mobile), now indicator - Graph: react-force-graph-2d w/ custom node/link canvas renderers, hover highlighting, search-to-fly, filter panel (entity + relationship types), ResizeObserver, 500-node cap, zoom controls - Command palette: cmdk polish (recent items, @mention filter, settings/logout actions, mobile full-screen) - Shortcuts: @github/hotkey for g+letter nav, n+letter create, ?, /, c; Cmd+K for palette - README.md added with architecture, page list, shortcuts, dev guide - Bundle: 369 KB gzipped (under 1.5 MB budget)
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
# Project E — Web SPA
|
||||||
|
|
||||||
|
The frontend SPA for Project E, built with React 19, TanStack Router, and Vite.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── shell/ # App shell: Sidebar, Topbar, CommandPalette, ShortcutsHelp, ThemeProvider
|
||||||
|
│ │ ├── ui/ # shadcn/ui primitives (button, dialog, input, etc.)
|
||||||
|
│ │ └── entities/ # Entity-specific components (detail panels, forms)
|
||||||
|
│ ├── hooks/ # Custom hooks (use-keyboard-shortcuts, use-realtime)
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api.ts # Typed API client + React Query hooks
|
||||||
|
│ │ ├── stores/ # Zustand stores (theme, sidebar, keyboard-shortcuts, create-dialog)
|
||||||
|
│ │ ├── types/ # Shared TypeScript types
|
||||||
|
│ │ └── utils.ts # cn() utility
|
||||||
|
│ ├── routes/ # TanStack Router file-based routes
|
||||||
|
│ │ ├── __root.tsx # Root layout
|
||||||
|
│ │ ├── _app.tsx # Authenticated app layout (sidebar + topbar + palette)
|
||||||
|
│ │ ├── login.tsx # Login page
|
||||||
|
│ │ └── _app/ # All app pages
|
||||||
|
│ ├── main.tsx # Entry point
|
||||||
|
│ ├── routeTree.ts # Auto-generated route tree
|
||||||
|
│ └── index.css # Tailwind + CSS variables
|
||||||
|
└── dist/ # Production build output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pages (14 routes)
|
||||||
|
|
||||||
|
| Route | Page | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `/` | Dashboard | Configurable widget grid (8 widget types) |
|
||||||
|
| `/tasks` | Tasks | Full CRUD with filters, sort, kanban view |
|
||||||
|
| `/habits` | Habits | Track habits with streaks, completions |
|
||||||
|
| `/projects` | Projects | Project management with sections |
|
||||||
|
| `/notes` | Notes | Rich text notes with backlinks |
|
||||||
|
| `/calendar` | Calendar | react-big-calendar with DnD, date-fns localizer |
|
||||||
|
| `/graph` | Graph | react-force-graph-2d with custom renderers |
|
||||||
|
| `/search` | Search | Full-text search across entities |
|
||||||
|
| `/analytics` | Analytics | 6 chart types (recharts) |
|
||||||
|
| `/agents/activity` | Agent Activity | SSE live activity feed |
|
||||||
|
| `/canvas` | Canvas | Block-based editor |
|
||||||
|
| `/daily` | Daily Notes | Journal with calendar sidebar |
|
||||||
|
| `/settings` | Settings | 9 tabs (profile, domains, agents, webhooks, etc.) |
|
||||||
|
| `/login` | Login | Authentication |
|
||||||
|
|
||||||
|
## OSS Components
|
||||||
|
|
||||||
|
- **Calendar:** `react-big-calendar` v1.20 with `withDragAndDrop` HOC, date-fns localizer, custom event renderer with color tokens, custom toolbar, responsive (agenda on mobile)
|
||||||
|
- **Graph:** `react-force-graph-2d` v1.29 with custom node/link canvas renderers, hover highlighting, search-to-fly, filter panel (entity + relationship types), ResizeObserver, 500-node cap
|
||||||
|
- **Command Palette:** `cmdk` v1.1 with recent items, @mention agent search, theme/settings actions, mobile full-screen
|
||||||
|
- **Shortcuts:** `@github/hotkey` v3.1 for global keyboard shortcuts (g+letter nav, n+letter create, ?, /, c)
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `⌘K` / `Ctrl+K` | Open command palette |
|
||||||
|
| `g then d` | Go to Dashboard |
|
||||||
|
| `g then t` | Go to Tasks |
|
||||||
|
| `g then h` | Go to Habits |
|
||||||
|
| `g then p` | Go to Projects |
|
||||||
|
| `g then n` | Go to Notes |
|
||||||
|
| `g then c` | Go to Calendar |
|
||||||
|
| `g then g` | Go to Graph |
|
||||||
|
| `g then s` | Go to Settings |
|
||||||
|
| `n then t` | New task |
|
||||||
|
| `n then h` | New habit |
|
||||||
|
| `n then p` | New project |
|
||||||
|
| `n then n` | New note |
|
||||||
|
| `c` | Focus create in palette |
|
||||||
|
| `/` | Focus search |
|
||||||
|
| `?` | Show shortcuts help |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Start dev server (port 3000)
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Preview production build
|
||||||
|
bun run preview
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All API calls go through `/api/*` and are proxied to the Hono backend. The API client in `src/lib/api.ts` provides typed `get`/`post`/`put`/`patch`/`delete` methods plus React Query hooks (`useApiQuery`, `useApiMutation`).
|
||||||
|
|
||||||
|
## Bundle Size
|
||||||
|
|
||||||
|
- Total JS: ~1.2 MB (369 KB gzipped)
|
||||||
|
- CSS: ~53 KB (10 KB gzipped)
|
||||||
|
- Budget: < 1.5 MB gzipped
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
|
LogOut,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +53,21 @@ interface QuickAction {
|
|||||||
action: () => void;
|
action: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recent items store (last 5 visited pages)
|
||||||
|
const RECENT_KEY = "project-e-recent-pages";
|
||||||
|
function getRecentPages(): string[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addRecentPage(href: string) {
|
||||||
|
const recent = getRecentPages().filter((p) => p !== href);
|
||||||
|
recent.unshift(href);
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, 5)));
|
||||||
|
}
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { mode, setMode, accent, setAccent } = useThemeStore();
|
const { mode, setMode, accent, setAccent } = useThemeStore();
|
||||||
@@ -60,6 +76,14 @@ export function CommandPalette() {
|
|||||||
Array<{ type: string; items: Array<{ id: string; title: string }> }>
|
Array<{ type: string; items: Array<{ id: string; title: string }> }>
|
||||||
>([]);
|
>([]);
|
||||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setIsMobile(window.innerWidth < 768);
|
||||||
|
check();
|
||||||
|
window.addEventListener("resize", check);
|
||||||
|
return () => window.removeEventListener("resize", check);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Listen for open-command-palette event
|
// Listen for open-command-palette event
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,6 +92,12 @@ export function CommandPalette() {
|
|||||||
return () => document.removeEventListener("open-command-palette", handler);
|
return () => document.removeEventListener("open-command-palette", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Track page navigation for recent items
|
||||||
|
useEffect(() => {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path !== "/login") addRecentPage(path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Quick actions
|
// Quick actions
|
||||||
const quickActions: QuickAction[] = [
|
const quickActions: QuickAction[] = [
|
||||||
{
|
{
|
||||||
@@ -113,6 +143,19 @@ export function CommandPalette() {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Settings actions
|
||||||
|
const settingsActions: QuickAction[] = [
|
||||||
|
{
|
||||||
|
label: "Log out",
|
||||||
|
icon: LogOut,
|
||||||
|
action: () => {
|
||||||
|
fetch("/api/auth/logout", { method: "POST" }).then(() => {
|
||||||
|
window.location.href = "/login";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Search handler
|
// Search handler
|
||||||
const handleSearch = useCallback(async (query: string) => {
|
const handleSearch = useCallback(async (query: string) => {
|
||||||
if (searchTimeoutRef.current) {
|
if (searchTimeoutRef.current) {
|
||||||
@@ -171,12 +214,18 @@ export function CommandPalette() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Recent pages
|
||||||
|
const recentPages = getRecentPages();
|
||||||
|
const recentNavItems = recentPages
|
||||||
|
.map((href) => navItems.find((item) => item.href === href))
|
||||||
|
.filter(Boolean) as NavItem[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog
|
<CommandDialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
label="Command palette"
|
label="Command palette"
|
||||||
className="max-w-lg"
|
className={isMobile ? "max-w-full h-full rounded-none border-0" : "max-w-lg"}
|
||||||
>
|
>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Type a command or search..."
|
placeholder="Type a command or search..."
|
||||||
@@ -185,6 +234,21 @@ export function CommandPalette() {
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
|
||||||
|
{/* Recent items */}
|
||||||
|
{recentNavItems.length > 0 && (
|
||||||
|
<CommandGroup heading="Recent">
|
||||||
|
{recentNavItems.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.href}
|
||||||
|
onSelect={() => runCommand(() => navigate({ to: item.href }))}
|
||||||
|
>
|
||||||
|
<item.icon className="mr-2 h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<CommandGroup heading="Jump to">
|
<CommandGroup heading="Jump to">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
@@ -224,6 +288,19 @@ export function CommandPalette() {
|
|||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<CommandGroup heading="Settings">
|
||||||
|
{settingsActions.map((action) => (
|
||||||
|
<CommandItem
|
||||||
|
key={action.label}
|
||||||
|
onSelect={() => runCommand(action.action)}
|
||||||
|
>
|
||||||
|
<action.icon className="mr-2 h-4 w-4" />
|
||||||
|
{action.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
{/* Search Results */}
|
{/* Search Results */}
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ const shortcutGroups = [
|
|||||||
{ keys: "g then s", description: "Go to Settings" },
|
{ keys: "g then s", description: "Go to Settings" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
heading: "Create",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: "n then t", description: "New task" },
|
||||||
|
{ keys: "n then h", description: "New habit" },
|
||||||
|
{ keys: "n then p", description: "New project" },
|
||||||
|
{ keys: "n then n", description: "New note" },
|
||||||
|
{ keys: "c", description: "Focus create in palette" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
heading: "General",
|
heading: "General",
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
@@ -55,7 +65,7 @@ export function ShortcutsHelp() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Use these shortcuts to navigate quickly.
|
Use these shortcuts to navigate quickly. Press two-key combos like "g then t" in sequence within 1.5 seconds.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1,17 +1,69 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store";
|
||||||
|
import { install, uninstall } from "@github/hotkey";
|
||||||
|
|
||||||
export function useKeyboardShortcuts() {
|
export function useKeyboardShortcuts() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { enabled } = useKeyboardShortcutsStore();
|
const { enabled } = useKeyboardShortcutsStore();
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
let pendingKey = "";
|
// Create a hidden container for @github/hotkey installs
|
||||||
let pendingTimeout: ReturnType<typeof setTimeout>;
|
const container = document.createElement("div");
|
||||||
|
container.setAttribute("aria-hidden", "true");
|
||||||
|
container.style.display = "none";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
containerRef.current = container;
|
||||||
|
|
||||||
|
// Helper: install a hotkey on a synthetic element
|
||||||
|
const addHotkey = (hotkey: string, handler: () => void) => {
|
||||||
|
const el = document.createElement("span");
|
||||||
|
el.setAttribute("data-hotkey", hotkey);
|
||||||
|
el.addEventListener("hotkey-fire", (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handler();
|
||||||
|
});
|
||||||
|
container.appendChild(el);
|
||||||
|
install(el, hotkey);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
// g+letter navigation sequences
|
||||||
|
const navMap: Record<string, string> = {
|
||||||
|
"g d": "/",
|
||||||
|
"g t": "/tasks",
|
||||||
|
"g h": "/habits",
|
||||||
|
"g p": "/projects",
|
||||||
|
"g n": "/notes",
|
||||||
|
"g c": "/calendar",
|
||||||
|
"g g": "/graph",
|
||||||
|
"g s": "/settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [seq, path] of Object.entries(navMap)) {
|
||||||
|
addHotkey(seq, () => {
|
||||||
|
navigate({ to: path });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// n+letter new-entity sequences
|
||||||
|
const newMap: Record<string, string> = {
|
||||||
|
"n t": "/tasks",
|
||||||
|
"n h": "/habits",
|
||||||
|
"n p": "/projects",
|
||||||
|
"n n": "/notes",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [seq, path] of Object.entries(newMap)) {
|
||||||
|
addHotkey(seq, () => {
|
||||||
|
navigate({ to: path });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global keydown handler for single-key shortcuts and Cmd+K
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Never override native behavior inside controls or modal UI
|
// Never override native behavior inside controls or modal UI
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -19,7 +71,6 @@ export function useKeyboardShortcuts() {
|
|||||||
target.tagName === "INPUT" ||
|
target.tagName === "INPUT" ||
|
||||||
target.tagName === "TEXTAREA" ||
|
target.tagName === "TEXTAREA" ||
|
||||||
target.tagName === "SELECT" ||
|
target.tagName === "SELECT" ||
|
||||||
target.tagName === "BUTTON" ||
|
|
||||||
target.isContentEditable ||
|
target.isContentEditable ||
|
||||||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
|
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
|
||||||
) {
|
) {
|
||||||
@@ -33,61 +84,35 @@ export function useKeyboardShortcuts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ? — show shortcuts help
|
// Single-key shortcuts (no modifiers)
|
||||||
if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
document.dispatchEvent(new CustomEvent("open-shortcuts-help"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// / — focus search (when not in an input)
|
|
||||||
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore if modifier keys are pressed
|
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
|
||||||
const key = e.key.toLowerCase();
|
switch (e.key) {
|
||||||
|
case "?":
|
||||||
// Handle two-key combos (G then letter)
|
|
||||||
if (pendingKey) {
|
|
||||||
clearTimeout(pendingTimeout);
|
|
||||||
pendingKey = "";
|
|
||||||
|
|
||||||
const navMap: Record<string, string> = {
|
|
||||||
d: "/",
|
|
||||||
t: "/tasks",
|
|
||||||
h: "/habits",
|
|
||||||
p: "/projects",
|
|
||||||
n: "/notes",
|
|
||||||
c: "/calendar",
|
|
||||||
g: "/graph",
|
|
||||||
s: "/settings",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (navMap[key]) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate({ to: navMap[key] });
|
document.dispatchEvent(new CustomEvent("open-shortcuts-help"));
|
||||||
}
|
break;
|
||||||
return;
|
case "/":
|
||||||
}
|
|
||||||
|
|
||||||
// Single key shortcuts
|
|
||||||
switch (key) {
|
|
||||||
case "g":
|
|
||||||
pendingKey = "g";
|
|
||||||
pendingTimeout = setTimeout(() => {
|
|
||||||
pendingKey = "";
|
|
||||||
}, 1000);
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||||
|
break;
|
||||||
|
case "c":
|
||||||
|
e.preventDefault();
|
||||||
|
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
for (const child of container.children) {
|
||||||
|
uninstall(child as HTMLElement);
|
||||||
|
}
|
||||||
|
document.body.removeChild(container);
|
||||||
|
containerRef.current = null;
|
||||||
|
};
|
||||||
}, [navigate, enabled]);
|
}, [navigate, enabled]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,105 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
import { createRoute } from "@tanstack/react-router";
|
import { createRoute } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
import type { CalendarEvent, PaginatedResponse } from "@/lib/types";
|
import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns";
|
||||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isSameDay, isToday, parseISO } from "date-fns";
|
|
||||||
|
// react-big-calendar
|
||||||
|
import { Calendar, dateFnsLocalizer, Views, Navigate } from "react-big-calendar";
|
||||||
|
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
|
||||||
|
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||||
|
|
||||||
|
const localizer = dateFnsLocalizer({
|
||||||
|
startOfWeek,
|
||||||
|
getDay,
|
||||||
|
format,
|
||||||
|
locales: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const DragAndDropCalendar = withDragAndDrop(Calendar);
|
||||||
|
|
||||||
|
// Color palette: tasks=accent, events by domain
|
||||||
|
const EVENT_COLORS: Record<string, string> = {
|
||||||
|
task: "var(--accent-hsl, 217 91% 60%)",
|
||||||
|
habit: "142 71% 45%",
|
||||||
|
project: "271 81% 56%",
|
||||||
|
note: "24 95% 53%",
|
||||||
|
default: "215 16% 47%",
|
||||||
|
};
|
||||||
|
|
||||||
|
function eventColor(event: CalendarEvent): string {
|
||||||
|
const hue = event.entityType ? EVENT_COLORS[event.entityType] || EVENT_COLORS.default : EVENT_COLORS.default;
|
||||||
|
return `hsl(${hue})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventBg(event: CalendarEvent): string {
|
||||||
|
const hue = event.entityType ? EVENT_COLORS[event.entityType] || EVENT_COLORS.default : EVENT_COLORS.default;
|
||||||
|
return `hsla(${hue}, 0.15)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom event component
|
||||||
|
function EventComponent({ event }: { event: CalendarEvent }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rbc-event-content truncate px-1 py-0.5 text-xs"
|
||||||
|
style={{
|
||||||
|
borderLeft: `3px solid ${eventColor(event)}`,
|
||||||
|
backgroundColor: eventBg(event),
|
||||||
|
color: eventColor(event),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom toolbar
|
||||||
|
function CustomToolbar({ date, onNavigate, onView, view, label }: any) {
|
||||||
|
const goToBack = () => onNavigate(Navigate.PREVIOUS);
|
||||||
|
const goToNext = () => onNavigate(Navigate.NEXT);
|
||||||
|
const goToToday = () => onNavigate(Navigate.TODAY);
|
||||||
|
|
||||||
|
const viewNames = ["month", "week", "day", "agenda"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="icon" onClick={goToBack} aria-label="Previous">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={goToToday}>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" onClick={goToNext} aria-label="Next">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="ml-2 text-lg font-semibold">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{viewNames.map((name) => (
|
||||||
|
<Button
|
||||||
|
key={name}
|
||||||
|
variant={view === name ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onView(name)}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) {
|
function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -25,13 +109,11 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
|||||||
const [allDay, setAllDay] = useState(event?.allDay || false);
|
const [allDay, setAllDay] = useState(event?.allDay || false);
|
||||||
const [color, setColor] = useState(event?.color || "#3b82f6");
|
const [color, setColor] = useState(event?.color || "#3b82f6");
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useApiMutation<CalendarEvent, any>("post", "/calendar/events", {
|
||||||
mutationFn: (data: any) => api.post<CalendarEvent>("/calendar/events", data),
|
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useApiMutation<CalendarEvent, any>("patch", event ? `/calendar/events/${event.id}` : "", {
|
||||||
mutationFn: (data: any) => api.patch<CalendarEvent>("/calendar/events/" + event!.id, data),
|
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,42 +161,90 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
|||||||
|
|
||||||
function CalendarPage() {
|
function CalendarPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [date, setDate] = useState(new Date());
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
const [view, setView] = useState<string>("month");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [eventDetailOpen, setEventDetailOpen] = useState(false);
|
const [eventDetailOpen, setEventDetailOpen] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
useRealtime({ enabled: true });
|
useRealtime({ enabled: true });
|
||||||
|
|
||||||
const monthStart = startOfMonth(currentMonth);
|
useEffect(() => {
|
||||||
const monthEnd = endOfMonth(currentMonth);
|
const check = () => setIsMobile(window.innerWidth < 768);
|
||||||
const calStart = startOfWeek(monthStart);
|
check();
|
||||||
const calEnd = endOfWeek(monthEnd);
|
window.addEventListener("resize", check);
|
||||||
const days = eachDayOfInterval({ start: calStart, end: calEnd });
|
return () => window.removeEventListener("resize", check);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-switch to agenda on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile && view === "month") {
|
||||||
|
setView("agenda");
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
const { data: eventsData } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
const { data: eventsData } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
||||||
["calendar-events", currentMonth.toISOString()],
|
["calendar-events", date.toISOString()],
|
||||||
"/calendar/events?from=" + calStart.toISOString() + "&to=" + calEnd.toISOString()
|
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const events = eventsData?.items || [];
|
const events = eventsData?.items || [];
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useApiMutation<any, string>("delete", "", {
|
||||||
mutationFn: (id: string) => api.delete("/calendar/events/" + id),
|
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); setEventDetailOpen(false); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); setEventDetailOpen(false); },
|
||||||
});
|
});
|
||||||
|
|
||||||
const dayEvents = useMemo(() => {
|
// Map API events to react-big-calendar format
|
||||||
const map = new Map<string, CalendarEvent[]>();
|
const calendarEvents = useMemo(() => {
|
||||||
for (const event of events) {
|
return events.map((evt) => ({
|
||||||
const key = new Date(event.startTime).toISOString().slice(0, 10);
|
...evt,
|
||||||
if (!map.has(key)) map.set(key, []);
|
start: parseISO(evt.startTime),
|
||||||
map.get(key)!.push(event);
|
end: evt.endTime ? parseISO(evt.endTime) : addDays(parseISO(evt.startTime), 1),
|
||||||
}
|
}));
|
||||||
return map;
|
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
|
const handleSelectEvent = useCallback((event: any) => {
|
||||||
|
setSelectedEvent(event as CalendarEvent);
|
||||||
|
setEventDetailOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectSlot = useCallback(() => {
|
||||||
|
setCreateOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEventDrop = useCallback(
|
||||||
|
({ event, start, end }: { event: any; start: Date; end: Date }) => {
|
||||||
|
api.patch(`/calendar/events/${event.id}`, {
|
||||||
|
startTime: start.toISOString(),
|
||||||
|
endTime: end.toISOString(),
|
||||||
|
}).then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEventResize = useCallback(
|
||||||
|
({ event, start, end }: { event: any; start: Date; end: Date }) => {
|
||||||
|
api.patch(`/calendar/events/${event.id}`, {
|
||||||
|
startTime: start.toISOString(),
|
||||||
|
endTime: end.toISOString(),
|
||||||
|
}).then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback((newDate: Date) => {
|
||||||
|
setDate(newDate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewChange = useCallback((newView: string) => {
|
||||||
|
setView(newView);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -130,70 +260,34 @@ function CalendarPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
|
||||||
<div className="flex items-center justify-between">
|
<DragAndDropCalendar
|
||||||
<div className="flex items-center gap-2">
|
localizer={localizer}
|
||||||
<Button variant="outline" size="icon" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))} aria-label="Previous month">
|
events={calendarEvents}
|
||||||
<ChevronLeft className="h-4 w-4" />
|
startAccessor="start"
|
||||||
</Button>
|
endAccessor="end"
|
||||||
<Button variant="outline" size="icon" onClick={() => setCurrentMonth(new Date())} aria-label="Today">
|
date={date}
|
||||||
Today
|
view={view}
|
||||||
</Button>
|
onNavigate={handleNavigate}
|
||||||
<Button variant="outline" size="icon" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))} aria-label="Next month">
|
onView={handleViewChange}
|
||||||
<ChevronRight className="h-4 w-4" />
|
onSelectEvent={handleSelectEvent}
|
||||||
</Button>
|
onSelectSlot={handleSelectSlot}
|
||||||
</div>
|
onEventDrop={handleEventDrop}
|
||||||
<h2 className="text-lg font-semibold">{format(currentMonth, "MMMM yyyy")}</h2>
|
onEventResize={handleEventResize}
|
||||||
</div>
|
selectable
|
||||||
|
resizable
|
||||||
{/* Calendar grid */}
|
popup
|
||||||
<div className="border rounded-lg">
|
showMultiDayTimes
|
||||||
<div className="grid grid-cols-7 border-b">
|
components={{
|
||||||
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
|
event: EventComponent,
|
||||||
<div key={day} className="p-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0">
|
toolbar: CustomToolbar,
|
||||||
{day}
|
}}
|
||||||
</div>
|
defaultView={Views.MONTH}
|
||||||
))}
|
views={["month", "week", "work_week", "day", "agenda"]}
|
||||||
</div>
|
step={30}
|
||||||
<div className="grid grid-cols-7">
|
timeslots={2}
|
||||||
{days.map((day) => {
|
style={{ height: isMobile ? 400 : 600 }}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Event detail dialog */}
|
{/* Event detail dialog */}
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
||||||
import { createRoute } from "@tanstack/react-router";
|
import { createRoute } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter } from "lucide-react";
|
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
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 { Separator } from "@/components/ui/separator";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { GraphNode, GraphEdge } from "@/lib/types";
|
import type { GraphNode, GraphEdge } from "@/lib/types";
|
||||||
|
import ForceGraph2D from "react-force-graph-2d";
|
||||||
|
|
||||||
const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"];
|
const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"];
|
||||||
|
const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to"];
|
||||||
|
|
||||||
const ENTITY_COLORS: Record<string, string> = {
|
const ENTITY_COLORS: Record<string, string> = {
|
||||||
task: "#3b82f6",
|
task: "#3b82f6",
|
||||||
habit: "#10b981",
|
habit: "#10b981",
|
||||||
@@ -28,17 +30,42 @@ const ENTITY_COLORS: Record<string, string> = {
|
|||||||
domain: "#6366f1",
|
domain: "#6366f1",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_NODES = 500;
|
||||||
|
|
||||||
function GraphPage() {
|
function GraphPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const graphRef = useRef<any>(undefined);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(new Set(ENTITY_TYPES));
|
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(new Set(ENTITY_TYPES));
|
||||||
|
const [enabledRelationships, setEnabledRelationships] = useState<Set<string>>(new Set(RELATIONSHIP_TYPES));
|
||||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
|
||||||
useRealtime({ enabled: true });
|
useRealtime({ enabled: true });
|
||||||
|
|
||||||
|
// ResizeObserver for reactive sizing
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width, height } = entry.contentRect;
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
setDimensions({ width, height });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
||||||
["graph", "nodes"],
|
["graph", "nodes"],
|
||||||
"/graph/nodes?domain=placeholder"
|
"/graph/nodes?domain=placeholder"
|
||||||
@@ -52,9 +79,57 @@ function GraphPage() {
|
|||||||
const allNodes = nodesData?.items || [];
|
const allNodes = nodesData?.items || [];
|
||||||
const allEdges = edgesData?.items || [];
|
const allEdges = edgesData?.items || [];
|
||||||
|
|
||||||
|
// Filter by entity type
|
||||||
const filteredNodes = allNodes.filter((n) => enabledTypes.has(n.type));
|
const filteredNodes = allNodes.filter((n) => enabledTypes.has(n.type));
|
||||||
const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
|
const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
|
||||||
const filteredEdges = allEdges.filter((e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target));
|
const filteredEdges = allEdges.filter(
|
||||||
|
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target) && enabledRelationships.has(e.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Performance cap
|
||||||
|
const displayNodes = filteredNodes.slice(0, MAX_NODES);
|
||||||
|
const displayNodeIds = new Set(displayNodes.map((n) => n.id));
|
||||||
|
const displayEdges = filteredEdges.filter(
|
||||||
|
(e) => displayNodeIds.has(e.source) && displayNodeIds.has(e.target)
|
||||||
|
);
|
||||||
|
const exceeded = filteredNodes.length > MAX_NODES;
|
||||||
|
|
||||||
|
// Graph data for react-force-graph-2d
|
||||||
|
const graphData = useMemo(() => ({
|
||||||
|
nodes: displayNodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
label: n.label,
|
||||||
|
type: n.type,
|
||||||
|
color: n.color || ENTITY_COLORS[n.type] || "#6b7280",
|
||||||
|
})),
|
||||||
|
links: displayEdges.map((e) => ({
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
type: e.type,
|
||||||
|
})),
|
||||||
|
}), [displayNodes, displayEdges]);
|
||||||
|
|
||||||
|
// Hover highlight: connected nodes/edges
|
||||||
|
const highlightNodes = useMemo(() => {
|
||||||
|
if (!hoveredNode) return new Set<string>();
|
||||||
|
const connected = new Set<string>([hoveredNode.id]);
|
||||||
|
displayEdges.forEach((e) => {
|
||||||
|
if (e.source === hoveredNode.id) connected.add(e.target);
|
||||||
|
if (e.target === hoveredNode.id) connected.add(e.source);
|
||||||
|
});
|
||||||
|
return connected;
|
||||||
|
}, [hoveredNode, displayEdges]);
|
||||||
|
|
||||||
|
const highlightLinks = useMemo(() => {
|
||||||
|
if (!hoveredNode) return new Set<string>();
|
||||||
|
const connected = new Set<string>();
|
||||||
|
displayEdges.forEach((e) => {
|
||||||
|
if (e.source === hoveredNode.id || e.target === hoveredNode.id) {
|
||||||
|
connected.add(`${e.source}-${e.target}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return connected;
|
||||||
|
}, [hoveredNode, displayEdges]);
|
||||||
|
|
||||||
const toggleType = (type: string) => {
|
const toggleType = (type: string) => {
|
||||||
const next = new Set(enabledTypes);
|
const next = new Set(enabledTypes);
|
||||||
@@ -63,6 +138,128 @@ function GraphPage() {
|
|||||||
setEnabledTypes(next);
|
setEnabledTypes(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleRelationship = (type: string) => {
|
||||||
|
const next = new Set(enabledRelationships);
|
||||||
|
if (next.has(type)) next.delete(type);
|
||||||
|
else next.add(type);
|
||||||
|
setEnabledRelationships(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search: fly to node
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
if (!search.trim() || !graphRef.current) return;
|
||||||
|
const found = displayNodes.find(
|
||||||
|
(n) => n.label.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
graphRef.current.centerAt(found.x, found.y, 1000);
|
||||||
|
graphRef.current.zoom(3, 1000);
|
||||||
|
}
|
||||||
|
}, [search, displayNodes]);
|
||||||
|
|
||||||
|
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") handleSearch();
|
||||||
|
}, [handleSearch]);
|
||||||
|
|
||||||
|
// Node click → side panel
|
||||||
|
const handleNodeClick = useCallback((node: any) => {
|
||||||
|
setSelectedNode(node as GraphNode);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Node hover → highlight
|
||||||
|
const handleNodeHover = useCallback((node: any | null) => {
|
||||||
|
setHoveredNode(node as GraphNode | null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Custom node renderer
|
||||||
|
const nodeCanvasObject = useCallback(
|
||||||
|
(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||||
|
const isHighlighted = highlightNodes.size === 0 || highlightNodes.has(node.id);
|
||||||
|
const isHovered = hoveredNode?.id === node.id;
|
||||||
|
const label = node.label || "";
|
||||||
|
const fontSize = Math.max(8, 12 / globalScale);
|
||||||
|
const radius = isHovered ? 8 : 6;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = isHighlighted ? node.color : `${node.color}33`;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = isHovered ? "#fff" : "#fff";
|
||||||
|
ctx.lineWidth = isHovered ? 2 / globalScale : 1 / globalScale;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Label below node
|
||||||
|
ctx.font = `${fontSize}px Sans-Serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
ctx.fillStyle = isHighlighted
|
||||||
|
? (document.documentElement.classList.contains("dark") ? "#e2e8f0" : "#1e293b")
|
||||||
|
: "#94a3b8";
|
||||||
|
const displayLabel = label.length > 15 ? label.slice(0, 15) + "…" : label;
|
||||||
|
ctx.fillText(displayLabel, node.x, node.y + radius + 2);
|
||||||
|
},
|
||||||
|
[highlightNodes, hoveredNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom link renderer with arrows
|
||||||
|
const linkCanvasObject = useCallback(
|
||||||
|
(link: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||||
|
const isHighlighted = highlightLinks.size === 0 || highlightLinks.has(`${link.source.id}-${link.target.id}`);
|
||||||
|
const width = isHighlighted ? 1.5 / globalScale : 0.5 / globalScale;
|
||||||
|
const opacity = isHighlighted ? 0.6 : 0.1;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(link.source.x, link.source.y);
|
||||||
|
ctx.lineTo(link.target.x, link.target.y);
|
||||||
|
ctx.strokeStyle = `rgba(148, 163, 184, ${opacity})`;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Arrow at midpoint
|
||||||
|
if (isHighlighted && globalScale > 0.5) {
|
||||||
|
const midX = (link.source.x + link.target.x) / 2;
|
||||||
|
const midY = (link.source.y + link.target.y) / 2;
|
||||||
|
const dx = link.target.x - link.source.x;
|
||||||
|
const dy = link.target.y - link.source.y;
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (len > 0) {
|
||||||
|
const ux = dx / len;
|
||||||
|
const uy = dy / len;
|
||||||
|
const arrowSize = 4 / globalScale;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(midX + ux * arrowSize, midY + uy * arrowSize);
|
||||||
|
ctx.lineTo(midX - ux * arrowSize + uy * arrowSize * 0.5, midY - uy * arrowSize - ux * arrowSize * 0.5);
|
||||||
|
ctx.lineTo(midX - ux * arrowSize - uy * arrowSize * 0.5, midY - uy * arrowSize + ux * arrowSize * 0.5);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = `rgba(148, 163, 184, ${opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[highlightLinks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
if (graphRef.current) {
|
||||||
|
const newZoom = Math.min(graphRef.current.zoom() * 1.3, 10);
|
||||||
|
graphRef.current.zoom(newZoom, 400);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
if (graphRef.current) {
|
||||||
|
const newZoom = Math.max(graphRef.current.zoom() / 1.3, 0.1);
|
||||||
|
graphRef.current.zoom(newZoom, 400);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
if (graphRef.current) {
|
||||||
|
graphRef.current.zoomToFit(400, 50);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
<div className="flex h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||||
{/* Graph canvas area */}
|
{/* Graph canvas area */}
|
||||||
@@ -75,6 +272,7 @@ function GraphPage() {
|
|||||||
placeholder="Find a node..."
|
placeholder="Find a node..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
className="pl-8 w-64 bg-background/90 backdrop-blur"
|
className="pl-8 w-64 bg-background/90 backdrop-blur"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,76 +281,91 @@ function GraphPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Graph visualization */}
|
{/* Zoom controls */}
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-1">
|
||||||
<div className="text-center text-muted-foreground">
|
<Button variant="secondary" size="icon" onClick={handleZoomIn} aria-label="Zoom in">
|
||||||
<svg width="400" height="400" viewBox="0 0 400 400" className="mx-auto mb-4">
|
<ZoomIn className="h-4 w-4" />
|
||||||
{/* Simple force-directed graph visualization */}
|
</Button>
|
||||||
{filteredEdges.map((edge, i) => {
|
<Button variant="secondary" size="icon" onClick={handleZoomOut} aria-label="Zoom out">
|
||||||
const source = filteredNodes.find((n) => n.id === edge.source);
|
<ZoomOut className="h-4 w-4" />
|
||||||
const target = filteredNodes.find((n) => n.id === edge.target);
|
</Button>
|
||||||
if (!source || !target) return null;
|
<Button variant="secondary" size="icon" onClick={handleReset} aria-label="Reset view">
|
||||||
// Simple circular layout
|
<RotateCcw className="h-4 w-4" />
|
||||||
const srcIdx = filteredNodes.indexOf(source);
|
</Button>
|
||||||
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>
|
||||||
|
|
||||||
|
{/* Performance warning */}
|
||||||
|
{exceeded && (
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
Showing {MAX_NODES} of {filteredNodes.length} nodes (cap reached)
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Force graph */}
|
||||||
|
<ForceGraph2D
|
||||||
|
ref={graphRef}
|
||||||
|
graphData={graphData}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
nodeCanvasObject={nodeCanvasObject}
|
||||||
|
linkCanvasObject={linkCanvasObject}
|
||||||
|
linkDirectionalArrowLength={0}
|
||||||
|
linkDirectionalArrowRelPos={0.5}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeHover={handleNodeHover}
|
||||||
|
nodeRelSize={6}
|
||||||
|
d3AlphaDecay={0.02}
|
||||||
|
d3VelocityDecay={0.3}
|
||||||
|
cooldownTicks={100}
|
||||||
|
warmupTicks={40}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter panel */}
|
{/* Filter panel */}
|
||||||
<Sheet open={filterOpen} onOpenChange={setFilterOpen}>
|
<Sheet open={filterOpen} onOpenChange={setFilterOpen}>
|
||||||
<SheetContent side="right" className="w-64">
|
<SheetContent side="right" className="w-72">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Filters</SheetTitle>
|
<SheetTitle>Filters</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="space-y-4 pt-4">
|
<ScrollArea className="h-full pr-4">
|
||||||
<h3 className="text-sm font-semibold">Entity Types</h3>
|
<div className="space-y-6 pt-4">
|
||||||
{ENTITY_TYPES.map((type) => (
|
<div>
|
||||||
<div key={type} className="flex items-center gap-2">
|
<h3 className="text-sm font-semibold mb-2">Entity Types</h3>
|
||||||
<Checkbox
|
{ENTITY_TYPES.map((type) => (
|
||||||
id={"type-" + type}
|
<div key={type} className="flex items-center gap-2 py-1">
|
||||||
checked={enabledTypes.has(type)}
|
<Checkbox
|
||||||
onCheckedChange={() => toggleType(type)}
|
id={"type-" + type}
|
||||||
/>
|
checked={enabledTypes.has(type)}
|
||||||
<Label htmlFor={"type-" + type} className="flex items-center gap-2 text-sm">
|
onCheckedChange={() => toggleType(type)}
|
||||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ENTITY_COLORS[type] }} />
|
/>
|
||||||
{type.charAt(0).toUpperCase() + type.slice(1)}s
|
<Label htmlFor={"type-" + type} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
</Label>
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ENTITY_COLORS[type] }} />
|
||||||
|
{type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Separator />
|
||||||
</div>
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Relationship Types</h3>
|
||||||
|
{RELATIONSHIP_TYPES.map((type) => (
|
||||||
|
<div key={type} className="flex items-center gap-2 py-1">
|
||||||
|
<Checkbox
|
||||||
|
id={"rel-" + type}
|
||||||
|
checked={enabledRelationships.has(type)}
|
||||||
|
onCheckedChange={() => toggleRelationship(type)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={"rel-" + type} className="text-sm cursor-pointer">
|
||||||
|
{type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
@@ -172,7 +385,7 @@ function GraphPage() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<h4 className="text-sm font-semibold">Connected nodes</h4>
|
<h4 className="text-sm font-semibold">Connected nodes</h4>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{filteredEdges
|
{displayEdges
|
||||||
.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id)
|
.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id)
|
||||||
.map((e, i) => {
|
.map((e, i) => {
|
||||||
const connectedId = e.source === selectedNode.id ? e.target : e.source;
|
const connectedId = e.source === selectedNode.id ? e.target : e.source;
|
||||||
@@ -180,8 +393,8 @@ function GraphPage() {
|
|||||||
return connected ? (
|
return connected ? (
|
||||||
<div key={i} className="flex items-center gap-2 text-sm py-1">
|
<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 }} />
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: connected.color }} />
|
||||||
<span className="truncate">{connected.label}</span>
|
<span className="truncate flex-1">{connected.label}</span>
|
||||||
<Badge variant="outline" className="text-[10px]">{e.type}</Badge>
|
<Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user