@knotpad/app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/app/(app)/calendar/page.tsx +57 -0
- package/app/(app)/error.tsx +35 -0
- package/app/(app)/graph/page.tsx +32 -0
- package/app/(app)/guide/page.tsx +21 -0
- package/app/(app)/kanban/loading.tsx +24 -0
- package/app/(app)/kanban/page.tsx +59 -0
- package/app/(app)/layout.tsx +122 -0
- package/app/(app)/list/loading.tsx +21 -0
- package/app/(app)/list/page.tsx +137 -0
- package/app/(app)/loading.tsx +18 -0
- package/app/(app)/notes/[noteId]/page.tsx +84 -0
- package/app/(app)/notes/layout.tsx +30 -0
- package/app/(app)/notes/page.tsx +39 -0
- package/app/(app)/page.tsx +5 -0
- package/app/(app)/settings/agent-token/page.tsx +59 -0
- package/app/(app)/settings/backup/page.tsx +49 -0
- package/app/(app)/settings/billing/page.tsx +53 -0
- package/app/(app)/settings/calendar/page.tsx +41 -0
- package/app/(app)/settings/layout.test.tsx +39 -0
- package/app/(app)/settings/layout.tsx +71 -0
- package/app/(app)/settings/page.tsx +4 -0
- package/app/(app)/settings/security/page.tsx +43 -0
- package/app/(app)/settings/team/page.tsx +74 -0
- package/app/(app)/settings/workspace/page.tsx +27 -0
- package/app/(app)/tasks/[taskId]/page.tsx +79 -0
- package/app/(auth)/forgot-password/page.tsx +106 -0
- package/app/(auth)/guest/page.tsx +56 -0
- package/app/(auth)/layout.tsx +13 -0
- package/app/(auth)/login/page.tsx +14 -0
- package/app/(auth)/register/page.tsx +193 -0
- package/app/(auth)/reset-password/page.tsx +138 -0
- package/app/api/account/claim/route.tsx +135 -0
- package/app/api/admin/backfill-encryption/route.tsx +43 -0
- package/app/api/admin/license/route.tsx +42 -0
- package/app/api/auth/2fa/route.tsx +148 -0
- package/app/api/auth/[...nextauth]/route.tsx +3 -0
- package/app/api/auth/change-password/route.tsx +61 -0
- package/app/api/auth/check-2fa/route.tsx +19 -0
- package/app/api/auth/forgot-password/route.tsx +65 -0
- package/app/api/auth/reset-password/route.tsx +52 -0
- package/app/api/auth/verify-2fa/route.tsx +88 -0
- package/app/api/backup/download/db/route.ts +29 -0
- package/app/api/backup/download/notes/route.ts +25 -0
- package/app/api/backup/settings/route.ts +92 -0
- package/app/api/billing/checkout/route.tsx +81 -0
- package/app/api/billing/migrate/route.tsx +163 -0
- package/app/api/billing/portal/route.tsx +24 -0
- package/app/api/billing/setup-intent/route.tsx +55 -0
- package/app/api/billing/status/route.tsx +36 -0
- package/app/api/billing/subscribe/route.tsx +85 -0
- package/app/api/billing/webhook/route.tsx +199 -0
- package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
- package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
- package/app/api/calendar-feeds/events/route.tsx +82 -0
- package/app/api/calendar-feeds/route.tsx +52 -0
- package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
- package/app/api/cron/calendar-feeds/route.tsx +31 -0
- package/app/api/cron/stale-tasks/route.tsx +51 -0
- package/app/api/cron/sync/route.tsx +34 -0
- package/app/api/devices/[deviceId]/route.tsx +25 -0
- package/app/api/devices/route.tsx +41 -0
- package/app/api/export/route.tsx +40 -0
- package/app/api/feedback/route.tsx +54 -0
- package/app/api/folders/[folderId]/route.tsx +51 -0
- package/app/api/folders/route.tsx +37 -0
- package/app/api/graph/route.tsx +242 -0
- package/app/api/guest/route.tsx +58 -0
- package/app/api/health/route.tsx +10 -0
- package/app/api/holidays/countries/route.tsx +14 -0
- package/app/api/holidays/route.tsx +49 -0
- package/app/api/holidays/states/route.tsx +21 -0
- package/app/api/invites/[token]/route.tsx +131 -0
- package/app/api/invites/route.tsx +74 -0
- package/app/api/mcp/generate-token/route.tsx +55 -0
- package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
- package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
- package/app/api/notes/[noteId]/export/route.tsx +45 -0
- package/app/api/notes/[noteId]/route.tsx +360 -0
- package/app/api/notes/route.tsx +112 -0
- package/app/api/notifications/route.tsx +44 -0
- package/app/api/register/route.tsx +67 -0
- package/app/api/restore/route.tsx +148 -0
- package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
- package/app/api/sync/conflicts/route.tsx +48 -0
- package/app/api/sync/status/route.tsx +49 -0
- package/app/api/sync/trigger/route.tsx +15 -0
- package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
- package/app/api/tasks/[taskId]/route.tsx +259 -0
- package/app/api/tasks/bulk/route.tsx +133 -0
- package/app/api/tasks/route.tsx +36 -0
- package/app/api/workspace/active/route.tsx +39 -0
- package/app/api/workspace/create-team/route.tsx +42 -0
- package/app/api/workspace/kanban-statuses/route.tsx +71 -0
- package/app/api/workspace/members/[memberId]/route.tsx +69 -0
- package/app/api/workspace/route.tsx +24 -0
- package/app/download/page.tsx +170 -0
- package/app/favicon.ico +0 -0
- package/app/generated/prisma/client.d.ts +1 -0
- package/app/generated/prisma/client.js +5 -0
- package/app/generated/prisma/default.d.ts +1 -0
- package/app/generated/prisma/default.js +5 -0
- package/app/generated/prisma/edge.d.ts +1 -0
- package/app/generated/prisma/edge.js +497 -0
- package/app/generated/prisma/index-browser.js +523 -0
- package/app/generated/prisma/index.d.ts +46376 -0
- package/app/generated/prisma/index.js +497 -0
- package/app/generated/prisma/package.json +144 -0
- package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/app/generated/prisma/runtime/client.d.ts +3386 -0
- package/app/generated/prisma/runtime/client.js +86 -0
- package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
- package/app/generated/prisma/runtime/index-browser.js +6 -0
- package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
- package/app/generated/prisma/schema.prisma +456 -0
- package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
- package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
- package/app/globals.css +54 -0
- package/app/invite/[token]/page.tsx +52 -0
- package/app/layout.tsx +90 -0
- package/app/mcp/route.tsx +430 -0
- package/app/opengraph-image.tsx +120 -0
- package/app/page.tsx +398 -0
- package/app/privacy/page.tsx +69 -0
- package/app/robots.tsx +25 -0
- package/app/sitemap.tsx +36 -0
- package/app/terms/page.tsx +69 -0
- package/app/upgrade/page.tsx +75 -0
- package/auth.config.ts +33 -0
- package/auth.ts +79 -0
- package/bin/brief.js +224 -0
- package/components/auth/login-form.tsx +302 -0
- package/components/auth/password-checklist.tsx +31 -0
- package/components/auth/password-input.tsx +36 -0
- package/components/auth/switch-account-button.test.tsx +22 -0
- package/components/auth/switch-account-button.tsx +19 -0
- package/components/auth/two-factor-input.tsx +116 -0
- package/components/billing/billing-dashboard.tsx +265 -0
- package/components/billing/card-form.tsx +210 -0
- package/components/billing/claim-account-form.tsx +99 -0
- package/components/branding/app-logo.test.tsx +20 -0
- package/components/branding/app-logo.tsx +25 -0
- package/components/calendar/calendar-agenda.tsx +150 -0
- package/components/calendar/calendar-drag.test.tsx +177 -0
- package/components/calendar/calendar-grid.tsx +357 -0
- package/components/calendar/calendar-hooks.test.tsx +27 -0
- package/components/calendar/calendar-hooks.ts +351 -0
- package/components/calendar/calendar-toolbar.test.tsx +68 -0
- package/components/calendar/calendar-toolbar.tsx +291 -0
- package/components/calendar/calendar-types.ts +148 -0
- package/components/calendar/calendar-view.test.tsx +295 -0
- package/components/calendar/calendar-view.tsx +307 -0
- package/components/calendar/day-detail-popover.tsx +174 -0
- package/components/calendar/task-chip.tsx +86 -0
- package/components/command/command-palette.test.tsx +33 -0
- package/components/command/command-palette.tsx +310 -0
- package/components/download-cta.tsx +87 -0
- package/components/feedback/feedback-popup.tsx +207 -0
- package/components/graph/graph-draw.ts +337 -0
- package/components/graph/graph-overlays.tsx +160 -0
- package/components/graph/graph-page.test.tsx +131 -0
- package/components/graph/graph-page.tsx +263 -0
- package/components/graph/graph-types.ts +47 -0
- package/components/graph/graph-view.tsx +322 -0
- package/components/guide/guide-view.tsx +522 -0
- package/components/kanban/kanban-board.test.tsx +128 -0
- package/components/kanban/kanban-board.tsx +361 -0
- package/components/kanban/kanban-card-menu.tsx +102 -0
- package/components/kanban/kanban-card.tsx +227 -0
- package/components/kanban/kanban-column.tsx +49 -0
- package/components/kanban/kanban-status-context.tsx +28 -0
- package/components/landing/calendar-sandbox.test.tsx +15 -0
- package/components/landing/calendar-sandbox.tsx +107 -0
- package/components/landing/graph-sandbox.test.tsx +27 -0
- package/components/landing/graph-sandbox.tsx +80 -0
- package/components/landing/kanban-sandbox.test.tsx +24 -0
- package/components/landing/kanban-sandbox.tsx +101 -0
- package/components/landing/landing-showcase.test.tsx +21 -0
- package/components/landing/landing-showcase.tsx +54 -0
- package/components/landing/list-sandbox.tsx +86 -0
- package/components/landing/mock-workspace.ts +168 -0
- package/components/landing/notes-sandbox.test.tsx +14 -0
- package/components/landing/notes-sandbox.tsx +88 -0
- package/components/layout/app-shell.tsx +83 -0
- package/components/layout/backup-scheduler.tsx +122 -0
- package/components/layout/bottom-nav.tsx +43 -0
- package/components/layout/icon-bar.test.tsx +29 -0
- package/components/layout/icon-bar.tsx +118 -0
- package/components/layout/mobile-top-bar.tsx +68 -0
- package/components/layout/notes-panel-folder.tsx +127 -0
- package/components/layout/notes-panel-note-item.tsx +140 -0
- package/components/layout/notes-panel-task-tab.tsx +63 -0
- package/components/layout/notes-panel-types.ts +44 -0
- package/components/layout/notes-panel.tsx +476 -0
- package/components/layout/notification-bell.tsx +251 -0
- package/components/layout/paywall-screen.tsx +41 -0
- package/components/layout/pro-banner.tsx +76 -0
- package/components/layout/sw-register.tsx +27 -0
- package/components/layout/workspace-switcher.tsx +90 -0
- package/components/notes/mobile-bottom-sheet.tsx +99 -0
- package/components/notes/note-editor-context-menu.tsx +47 -0
- package/components/notes/note-editor-dom.ts +33 -0
- package/components/notes/note-editor-dropdowns.tsx +484 -0
- package/components/notes/note-editor-hooks.ts +692 -0
- package/components/notes/note-editor-keyboard.ts +305 -0
- package/components/notes/note-editor-overlay.tsx +90 -0
- package/components/notes/note-editor.test.tsx +372 -0
- package/components/notes/note-editor.tsx +662 -0
- package/components/notes/note-preview-pane.tsx +156 -0
- package/components/notes/note-tabs.tsx +120 -0
- package/components/notes/note-types.tsx +157 -0
- package/components/settings/accept-invite.tsx +108 -0
- package/components/settings/agent-token-settings.tsx +369 -0
- package/components/settings/backup-restore-settings.test.tsx +25 -0
- package/components/settings/backup-restore-settings.tsx +327 -0
- package/components/settings/calendar-feeds-settings.tsx +489 -0
- package/components/settings/calendar-general-settings.tsx +174 -0
- package/components/settings/confirm-danger-action.test.tsx +215 -0
- package/components/settings/confirm-danger-action.tsx +65 -0
- package/components/settings/security-settings.tsx +252 -0
- package/components/settings/settings-guidance.test.tsx +98 -0
- package/components/settings/team-settings.tsx +319 -0
- package/components/settings/two-factor-auth.tsx +296 -0
- package/components/settings/workspace-settings-client.tsx +363 -0
- package/components/settings/workspace-settings-form.tsx +73 -0
- package/components/sync/conflict-viewer.tsx +247 -0
- package/components/sync/sync-indicator.tsx +171 -0
- package/components/tasks/snippet-thread.tsx +119 -0
- package/components/tasks/status-dot.tsx +47 -0
- package/components/tasks/task-badge.tsx +43 -0
- package/components/tasks/task-detail.test.tsx +187 -0
- package/components/tasks/task-detail.tsx +458 -0
- package/components/tasks/task-list-filters.test.tsx +75 -0
- package/components/tasks/task-list-filters.tsx +163 -0
- package/components/tasks/task-list-types.ts +20 -0
- package/components/tasks/task-list.test.tsx +175 -0
- package/components/tasks/task-list.tsx +481 -0
- package/components/tasks/task-row.tsx +85 -0
- package/components/tasks/task-table-row.tsx +259 -0
- package/components/ui/skeleton.tsx +3 -0
- package/components/ui/toast.test.tsx +42 -0
- package/components/ui/toast.tsx +70 -0
- package/electron/main.ts +251 -0
- package/electron/preload.ts +56 -0
- package/instrumentation.tsx +23 -0
- package/lib/api-error.ts +50 -0
- package/lib/backup/backup-runner.test.ts +32 -0
- package/lib/backup/backup-runner.ts +19 -0
- package/lib/backup/backup-schedule.test.ts +23 -0
- package/lib/backup/backup-schedule.ts +55 -0
- package/lib/backup/backup-settings.test.ts +30 -0
- package/lib/backup/backup-settings.ts +27 -0
- package/lib/backup/export-notes-zip.test.ts +26 -0
- package/lib/backup/export-notes-zip.ts +82 -0
- package/lib/backup/export-workspace-backup.test.ts +17 -0
- package/lib/backup/export-workspace-backup.ts +77 -0
- package/lib/backup/restore-workspace-from-export.test.ts +18 -0
- package/lib/backup/restore-workspace-from-export.ts +183 -0
- package/lib/backup/types.ts +14 -0
- package/lib/brand-icons.ts +1 -0
- package/lib/calendar-feed-crypto.ts +38 -0
- package/lib/calendar-feed.ts +239 -0
- package/lib/client/online-status.ts +47 -0
- package/lib/conflict-resolver.test.ts +57 -0
- package/lib/conflict-resolver.ts +240 -0
- package/lib/db-init.ts +79 -0
- package/lib/email.ts +159 -0
- package/lib/encryption.test.ts +41 -0
- package/lib/encryption.ts +98 -0
- package/lib/extract-snippet.test.ts +123 -0
- package/lib/extract-snippet.ts +69 -0
- package/lib/kanban-status.ts +55 -0
- package/lib/license.ts +21 -0
- package/lib/limits.ts +31 -0
- package/lib/mcp-auth.test.ts +58 -0
- package/lib/mcp-auth.ts +65 -0
- package/lib/mcp-contract.test.ts +25 -0
- package/lib/mcp-contract.ts +210 -0
- package/lib/mcp-handler.ts +31 -0
- package/lib/mcp-url.test.ts +12 -0
- package/lib/mcp-url.ts +7 -0
- package/lib/mentions.test.ts +45 -0
- package/lib/mentions.ts +73 -0
- package/lib/note-crypto.ts +108 -0
- package/lib/note-sync.ts +201 -0
- package/lib/note-title.ts +93 -0
- package/lib/prisma.ts +193 -0
- package/lib/pro-flush.ts +292 -0
- package/lib/rate-limit.ts +57 -0
- package/lib/stripe.ts +38 -0
- package/lib/sync-worker.ts +388 -0
- package/lib/task-parser.test.ts +91 -0
- package/lib/task-parser.ts +81 -0
- package/lib/task-utils.ts +52 -0
- package/lib/use-is-electron.ts +19 -0
- package/lib/use-is-mobile.ts +22 -0
- package/lib/validation/calendar-feed.ts +31 -0
- package/lib/validation/note.ts +27 -0
- package/lib/validation/task.ts +26 -0
- package/lib/view-preferences.test.ts +54 -0
- package/lib/view-preferences.ts +28 -0
- package/lib/workspace.ts +66 -0
- package/next.config.ts +21 -0
- package/package.json +99 -0
- package/postcss.config.mjs +7 -0
- package/prisma/migrations/20260519021916_init/migration.sql +388 -0
- package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
- package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
- package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
- package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
- package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
- package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
- package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
- package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
- package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
- package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
- package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
- package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
- package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
- package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
- package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
- package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
- package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +457 -0
- package/public/Logo_icon.svg +1 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +4 -0
- package/public/icon_dark.svg +1 -0
- package/public/knotpad_icon.svg +1 -0
- package/public/knotpad_logo_full.svg +1 -0
- package/public/manifest.json +14 -0
- package/public/next.svg +1 -0
- package/public/sw.js +137 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ExternalLink, RotateCcw, ZoomIn, ChevronDown, ChevronRight } from "lucide-react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import type { GraphNode } from "./graph-types";
|
|
6
|
+
import { NODE_COLORS, LEGEND_LABELS } from "./graph-types";
|
|
7
|
+
|
|
8
|
+
export function GraphLegend() {
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
{/* Mobile legend - compact horizontal at bottom */}
|
|
12
|
+
<div className="absolute bottom-3 left-3 right-3 z-10 flex md:hidden flex-wrap gap-x-3 gap-y-1.5 rounded-lg border border-zinc-800 bg-zinc-950/80 px-3 py-2 backdrop-blur text-[10px]">
|
|
13
|
+
{Object.entries(LEGEND_LABELS).map(([type, label]) => (
|
|
14
|
+
<div key={type} className="flex items-center gap-1.5">
|
|
15
|
+
<span
|
|
16
|
+
className="h-2 w-2 shrink-0 rounded-full"
|
|
17
|
+
style={{ backgroundColor: NODE_COLORS[type] }}
|
|
18
|
+
/>
|
|
19
|
+
<span className="text-zinc-500">{label}</span>
|
|
20
|
+
</div>
|
|
21
|
+
))}
|
|
22
|
+
<div className="flex items-center gap-1.5">
|
|
23
|
+
<span className="w-4 border-t border-zinc-500" />
|
|
24
|
+
<span className="text-zinc-600">Contains</span>
|
|
25
|
+
</div>
|
|
26
|
+
<div className="flex items-center gap-1.5">
|
|
27
|
+
<span className="w-4 border-t border-dashed border-zinc-600" />
|
|
28
|
+
<span className="text-zinc-600">Refs</span>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
{/* Desktop legend - vertical at top right */}
|
|
33
|
+
<div className="absolute top-3 right-3 z-10 hidden md:flex flex-col gap-1.5 rounded-lg border border-zinc-800 bg-zinc-950/80 px-3 py-2.5 backdrop-blur text-xs">
|
|
34
|
+
{Object.entries(LEGEND_LABELS).map(([type, label]) => (
|
|
35
|
+
<div key={type} className="flex items-center gap-2">
|
|
36
|
+
<span
|
|
37
|
+
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
|
38
|
+
style={{ backgroundColor: NODE_COLORS[type] }}
|
|
39
|
+
/>
|
|
40
|
+
<span className="text-zinc-500">{label}</span>
|
|
41
|
+
</div>
|
|
42
|
+
))}
|
|
43
|
+
<div className="mt-1 pt-1 border-t border-zinc-800 space-y-1">
|
|
44
|
+
<div className="flex items-center gap-2">
|
|
45
|
+
<span className="w-5 border-t border-zinc-500" />
|
|
46
|
+
<span className="text-zinc-600">Contains / assigned</span>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="flex items-center gap-2">
|
|
49
|
+
<span className="w-5 border-t border-dashed border-zinc-600" />
|
|
50
|
+
<span className="text-zinc-600">References</span>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<p className="text-zinc-700 pt-1 border-t border-zinc-800">Scroll to zoom · Drag to pan</p>
|
|
54
|
+
</div>
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function GraphTooltip({ tooltip }: { tooltip: { x: number; y: number; label: string; type: string } | null }) {
|
|
60
|
+
if (!tooltip) return null;
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
className="absolute z-20 pointer-events-none rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-300 shadow-lg"
|
|
64
|
+
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
|
65
|
+
>
|
|
66
|
+
<span className="font-medium">{tooltip.label}</span>
|
|
67
|
+
<span className="ml-1.5 text-zinc-500">{tooltip.type}</span>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function GraphContextMenu({
|
|
73
|
+
contextMenu,
|
|
74
|
+
contextNode,
|
|
75
|
+
collapsedNodeIds,
|
|
76
|
+
canCollapse,
|
|
77
|
+
onClose,
|
|
78
|
+
onToggleCollapse,
|
|
79
|
+
onZoomTo,
|
|
80
|
+
onFitView,
|
|
81
|
+
}: {
|
|
82
|
+
contextMenu: { x: number; y: number; nodeId: string } | null;
|
|
83
|
+
contextNode: GraphNode | null;
|
|
84
|
+
collapsedNodeIds: Set<string>;
|
|
85
|
+
canCollapse: (nodeId: string) => boolean;
|
|
86
|
+
onClose: () => void;
|
|
87
|
+
onToggleCollapse: (nodeId: string) => void;
|
|
88
|
+
onZoomTo: (nodeId: string) => void;
|
|
89
|
+
onFitView: () => void;
|
|
90
|
+
}) {
|
|
91
|
+
const router = useRouter();
|
|
92
|
+
if (!contextMenu || !contextNode) return null;
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
<div
|
|
96
|
+
className="fixed inset-0 z-20"
|
|
97
|
+
onClick={onClose}
|
|
98
|
+
onContextMenu={(e) => { e.preventDefault(); onClose(); }}
|
|
99
|
+
/>
|
|
100
|
+
<div
|
|
101
|
+
className="absolute z-30 w-40 rounded-md border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
|
102
|
+
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
103
|
+
>
|
|
104
|
+
{contextNode.href && (
|
|
105
|
+
<button
|
|
106
|
+
onClick={() => {
|
|
107
|
+
if (contextNode.href) router.push(contextNode.href);
|
|
108
|
+
onClose();
|
|
109
|
+
}}
|
|
110
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-800"
|
|
111
|
+
>
|
|
112
|
+
<ExternalLink size={11} className="text-zinc-500" />
|
|
113
|
+
Open
|
|
114
|
+
</button>
|
|
115
|
+
)}
|
|
116
|
+
{canCollapse(contextNode.id) && (
|
|
117
|
+
<button
|
|
118
|
+
onClick={() => {
|
|
119
|
+
onToggleCollapse(contextNode.id);
|
|
120
|
+
onClose();
|
|
121
|
+
}}
|
|
122
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-800"
|
|
123
|
+
>
|
|
124
|
+
{collapsedNodeIds.has(contextNode.id) ? (
|
|
125
|
+
<>
|
|
126
|
+
<ChevronRight size={11} className="text-zinc-500" />
|
|
127
|
+
Expand
|
|
128
|
+
</>
|
|
129
|
+
) : (
|
|
130
|
+
<>
|
|
131
|
+
<ChevronDown size={11} className="text-zinc-500" />
|
|
132
|
+
Collapse
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
</button>
|
|
136
|
+
)}
|
|
137
|
+
<button
|
|
138
|
+
onClick={() => {
|
|
139
|
+
onZoomTo(contextNode.id);
|
|
140
|
+
onClose();
|
|
141
|
+
}}
|
|
142
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-800"
|
|
143
|
+
>
|
|
144
|
+
<ZoomIn size={11} className="text-zinc-500" />
|
|
145
|
+
Zoom to
|
|
146
|
+
</button>
|
|
147
|
+
<button
|
|
148
|
+
onClick={() => {
|
|
149
|
+
onFitView();
|
|
150
|
+
onClose();
|
|
151
|
+
}}
|
|
152
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-800"
|
|
153
|
+
>
|
|
154
|
+
<RotateCcw size={11} className="text-zinc-500" />
|
|
155
|
+
Reset view
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
</>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen, within } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { GraphPage } from "@/components/graph/graph-page";
|
|
6
|
+
|
|
7
|
+
const push = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("next/navigation", () => ({
|
|
10
|
+
useRouter: () => ({ push }),
|
|
11
|
+
usePathname: () => "/graph",
|
|
12
|
+
useSearchParams: () => new URLSearchParams(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("@/components/graph/graph-view", () => ({
|
|
16
|
+
GraphView: ({
|
|
17
|
+
nodes,
|
|
18
|
+
onSelectionChange,
|
|
19
|
+
}: {
|
|
20
|
+
nodes?: Array<{ id: string; label: string; type: string }>;
|
|
21
|
+
onSelectionChange?: (node: { id: string; label: string; type: string } | null) => void;
|
|
22
|
+
}) => (
|
|
23
|
+
<div>
|
|
24
|
+
<div>Graph canvas</div>
|
|
25
|
+
<div>
|
|
26
|
+
{nodes?.map((node) => (
|
|
27
|
+
<span key={node.id}>{node.label}</span>
|
|
28
|
+
))}
|
|
29
|
+
</div>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={() => onSelectionChange?.({ id: "note-n1", label: "Launch", type: "note" })}
|
|
33
|
+
>
|
|
34
|
+
Select graph node
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
cleanup();
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
push.mockReset();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("GraphPage", () => {
|
|
47
|
+
it("filters out task nodes when the Tasks category is disabled", async () => {
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
|
|
50
|
+
vi.stubGlobal(
|
|
51
|
+
"fetch",
|
|
52
|
+
vi.fn().mockResolvedValue({
|
|
53
|
+
ok: true,
|
|
54
|
+
json: async () => ({
|
|
55
|
+
nodes: [
|
|
56
|
+
{ id: "note-n1", type: "note", label: "Launch note" },
|
|
57
|
+
{ id: "task-t1", type: "task", label: "Task node" },
|
|
58
|
+
],
|
|
59
|
+
edges: [{ source: "note-n1", target: "task-t1", type: "references" }],
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
render(<GraphPage selectedNoteIds={[]} />);
|
|
65
|
+
|
|
66
|
+
expect(await screen.findByText("Task node")).toBeInTheDocument();
|
|
67
|
+
|
|
68
|
+
await user.click(screen.getByRole("button", { name: "Tasks" }));
|
|
69
|
+
|
|
70
|
+
expect(screen.queryByText("Task node")).not.toBeInTheDocument();
|
|
71
|
+
expect(screen.getByText("Launch note")).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("shows inspector details for the selected node", async () => {
|
|
75
|
+
const user = userEvent.setup();
|
|
76
|
+
|
|
77
|
+
vi.stubGlobal(
|
|
78
|
+
"fetch",
|
|
79
|
+
vi.fn().mockResolvedValue({
|
|
80
|
+
ok: true,
|
|
81
|
+
json: async () => ({
|
|
82
|
+
nodes: [{ id: "note-n1", type: "note", label: "Launch", href: "/notes/n1" }],
|
|
83
|
+
edges: [],
|
|
84
|
+
}),
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
render(<GraphPage selectedNoteIds={["n1"]} />);
|
|
89
|
+
|
|
90
|
+
await user.click(await screen.findByRole("button", { name: "Select graph node" }));
|
|
91
|
+
|
|
92
|
+
const inspector = screen.getByText("Selected node").closest("aside");
|
|
93
|
+
expect(inspector).not.toBeNull();
|
|
94
|
+
expect(within(inspector!).getByText("Launch")).toBeInTheDocument();
|
|
95
|
+
expect(within(inspector!).getByText("note")).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("shows a help panel and selected note summary after loading", async () => {
|
|
99
|
+
vi.stubGlobal(
|
|
100
|
+
"fetch",
|
|
101
|
+
vi.fn().mockResolvedValue({
|
|
102
|
+
ok: true,
|
|
103
|
+
json: async () => ({
|
|
104
|
+
nodes: [{ id: "note-n1", type: "note", label: "Launch" }],
|
|
105
|
+
edges: [],
|
|
106
|
+
}),
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
render(<GraphPage selectedNoteIds={["n1"]} />);
|
|
111
|
+
|
|
112
|
+
expect(await screen.findByText(/how to read this graph/i)).toBeInTheDocument();
|
|
113
|
+
expect(screen.getByText("Selected")).toBeInTheDocument();
|
|
114
|
+
expect(screen.getAllByText("Launch").length).toBeGreaterThanOrEqual(2);
|
|
115
|
+
expect(screen.getByText("Graph canvas")).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("shows an error message when the graph request fails", async () => {
|
|
119
|
+
vi.stubGlobal(
|
|
120
|
+
"fetch",
|
|
121
|
+
vi.fn().mockResolvedValue({
|
|
122
|
+
ok: false,
|
|
123
|
+
json: async () => ({ message: "request failed" }),
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
render(<GraphPage selectedNoteIds={[]} />);
|
|
128
|
+
|
|
129
|
+
expect(await screen.findByText("Graph failed to load. Try refreshing.")).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
|
4
|
+
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
|
5
|
+
import { RotateCw, X, Maximize, Search } from "lucide-react";
|
|
6
|
+
import { GraphView, type GraphViewHandle } from "./graph-view";
|
|
7
|
+
import type { GraphData } from "@/app/api/graph/route";
|
|
8
|
+
import { LEGEND_LABELS, type GraphSelection } from "./graph-types";
|
|
9
|
+
import { loadPreference, savePreference } from "@/lib/view-preferences";
|
|
10
|
+
|
|
11
|
+
type VisibleNodeType = keyof typeof LEGEND_LABELS;
|
|
12
|
+
|
|
13
|
+
const GRAPH_NODE_TYPES = Object.keys(LEGEND_LABELS) as VisibleNodeType[];
|
|
14
|
+
|
|
15
|
+
export function GraphPage({ selectedNoteIds }: { selectedNoteIds: string[] }) {
|
|
16
|
+
const [data, setData] = useState<GraphData | null>(null);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
const [error, setError] = useState("");
|
|
19
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
20
|
+
const [selectedNode, setSelectedNode] = useState<GraphSelection | null>(null);
|
|
21
|
+
const [visibleTypes, setVisibleTypes] = useState<VisibleNodeType[]>(() =>
|
|
22
|
+
loadPreference("brief:graph-visible-types", GRAPH_NODE_TYPES)
|
|
23
|
+
);
|
|
24
|
+
const graphViewRef = useRef<GraphViewHandle>(null);
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
const searchParams = useSearchParams();
|
|
28
|
+
const allNodes = useMemo(() => data?.nodes ?? [], [data]);
|
|
29
|
+
const filteredNodes = useMemo(
|
|
30
|
+
() => allNodes.filter((node) => visibleTypes.includes(node.type as VisibleNodeType)),
|
|
31
|
+
[allNodes, visibleTypes]
|
|
32
|
+
);
|
|
33
|
+
const filteredEdges = useMemo(() => {
|
|
34
|
+
const visibleNodeIds = new Set(filteredNodes.map((node) => node.id));
|
|
35
|
+
return (data?.edges ?? []).filter(
|
|
36
|
+
(edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)
|
|
37
|
+
);
|
|
38
|
+
}, [data, filteredNodes]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
savePreference("brief:graph-visible-types", visibleTypes);
|
|
42
|
+
}, [visibleTypes]);
|
|
43
|
+
|
|
44
|
+
const load = useCallback(() => {
|
|
45
|
+
setLoading(true);
|
|
46
|
+
setError("");
|
|
47
|
+
fetch("/api/graph")
|
|
48
|
+
.then((r) => {
|
|
49
|
+
if (!r.ok) {
|
|
50
|
+
throw new Error("request failed");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return r.json();
|
|
54
|
+
})
|
|
55
|
+
.then((d: GraphData) => {
|
|
56
|
+
setData(d);
|
|
57
|
+
setError("");
|
|
58
|
+
})
|
|
59
|
+
.catch(() => {
|
|
60
|
+
setData(null);
|
|
61
|
+
setError("Graph failed to load. Try refreshing.");
|
|
62
|
+
})
|
|
63
|
+
.finally(() => {
|
|
64
|
+
setLoading(false);
|
|
65
|
+
});
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
useEffect(() => { load(); }, [load]);
|
|
69
|
+
|
|
70
|
+
// Escape key clears selection
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
73
|
+
if (e.key === "Escape") {
|
|
74
|
+
graphViewRef.current?.clearSelection();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
document.addEventListener("keydown", onKeyDown);
|
|
78
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const handleNoteSelect = useCallback((noteId: string | null) => {
|
|
82
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
83
|
+
if (noteId) {
|
|
84
|
+
params.set("notes", noteId);
|
|
85
|
+
} else {
|
|
86
|
+
params.delete("notes");
|
|
87
|
+
}
|
|
88
|
+
router.push(`${pathname}?${params.toString()}`);
|
|
89
|
+
}, [pathname, router, searchParams]);
|
|
90
|
+
|
|
91
|
+
const selectedSummary = useMemo(() => {
|
|
92
|
+
const labels = selectedNoteIds
|
|
93
|
+
.map((noteId) => filteredNodes.find((node) => node.id === `note-${noteId}`)?.label)
|
|
94
|
+
.filter((label): label is string => Boolean(label));
|
|
95
|
+
|
|
96
|
+
if (labels.length === 0) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (labels.length === 1) {
|
|
101
|
+
return labels[0];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return `${labels.length} notes selected`;
|
|
105
|
+
}, [filteredNodes, selectedNoteIds]);
|
|
106
|
+
const inspectorNode = useMemo(() => {
|
|
107
|
+
if (selectedNode) {
|
|
108
|
+
return filteredNodes.find((node) => node.id === selectedNode.id) ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (selectedNoteIds.length !== 1) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return filteredNodes.find((node) => node.id === `note-${selectedNoteIds[0]}`) ?? null;
|
|
116
|
+
}, [filteredNodes, selectedNode, selectedNoteIds]);
|
|
117
|
+
|
|
118
|
+
if (loading) {
|
|
119
|
+
return (
|
|
120
|
+
<div className="flex flex-1 items-center justify-center">
|
|
121
|
+
<div className="h-6 w-6 rounded-full border-2 border-zinc-700 border-t-zinc-400 animate-spin" />
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (error) {
|
|
127
|
+
return (
|
|
128
|
+
<div className="flex flex-1 items-center justify-center px-4 text-center text-sm text-red-300">
|
|
129
|
+
{error}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (allNodes.length === 0) {
|
|
135
|
+
return (
|
|
136
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-center">
|
|
137
|
+
<p className="text-sm font-medium text-zinc-500">Nothing to graph yet</p>
|
|
138
|
+
<p className="text-xs text-zinc-700 max-w-xs">
|
|
139
|
+
Create notes with tasks and @mentions to see your workspace as a relationship graph.
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div className="relative flex flex-1 flex-col overflow-hidden">
|
|
147
|
+
{/* Controls top-left */}
|
|
148
|
+
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
|
149
|
+
<button
|
|
150
|
+
onClick={load}
|
|
151
|
+
title="Refresh graph"
|
|
152
|
+
aria-label="Refresh graph"
|
|
153
|
+
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-800 bg-zinc-950/80 text-zinc-500 backdrop-blur hover:border-zinc-700 hover:text-zinc-300 transition-colors ring-focus"
|
|
154
|
+
>
|
|
155
|
+
<RotateCw size={13} />
|
|
156
|
+
</button>
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => graphViewRef.current?.clearSelection()}
|
|
159
|
+
title="Clear selection"
|
|
160
|
+
aria-label="Clear selection"
|
|
161
|
+
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-800 bg-zinc-950/80 text-zinc-500 backdrop-blur hover:border-zinc-700 hover:text-zinc-300 transition-colors ring-focus"
|
|
162
|
+
>
|
|
163
|
+
<X size={13} />
|
|
164
|
+
</button>
|
|
165
|
+
<button
|
|
166
|
+
onClick={() => graphViewRef.current?.fitView()}
|
|
167
|
+
title="Fit to view"
|
|
168
|
+
aria-label="Fit to view"
|
|
169
|
+
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-800 bg-zinc-950/80 text-zinc-500 backdrop-blur hover:border-zinc-700 hover:text-zinc-300 transition-colors ring-focus"
|
|
170
|
+
>
|
|
171
|
+
<Maximize size={13} />
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div className="absolute left-3 top-12 z-10 flex max-w-[min(24rem,calc(100vw-1.5rem))] flex-wrap gap-1.5 rounded-lg border border-zinc-800 bg-zinc-950/90 p-1.5 text-xs backdrop-blur">
|
|
176
|
+
{GRAPH_NODE_TYPES.map((type) => {
|
|
177
|
+
const active = visibleTypes.includes(type);
|
|
178
|
+
return (
|
|
179
|
+
<button
|
|
180
|
+
key={type}
|
|
181
|
+
type="button"
|
|
182
|
+
aria-pressed={active}
|
|
183
|
+
onClick={() => {
|
|
184
|
+
setSelectedNode((current) => (current?.type === type ? null : current));
|
|
185
|
+
setVisibleTypes((current) =>
|
|
186
|
+
current.includes(type)
|
|
187
|
+
? current.filter((value) => value !== type)
|
|
188
|
+
: [...current, type]
|
|
189
|
+
);
|
|
190
|
+
}}
|
|
191
|
+
className={`rounded-md border px-2 py-1 transition-colors ring-focus ${
|
|
192
|
+
active
|
|
193
|
+
? "border-zinc-700 bg-zinc-800 text-zinc-100"
|
|
194
|
+
: "border-zinc-800 bg-zinc-950 text-zinc-500 hover:border-zinc-700 hover:text-zinc-300"
|
|
195
|
+
}`}
|
|
196
|
+
>
|
|
197
|
+
{LEGEND_LABELS[type]}
|
|
198
|
+
</button>
|
|
199
|
+
);
|
|
200
|
+
})}
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Search bar */}
|
|
204
|
+
<div className="absolute top-3 left-1/2 z-10 flex w-[min(16rem,calc(100vw-1.5rem))] -translate-x-1/2 items-center gap-1.5 rounded border border-zinc-800 bg-zinc-950/80 px-2 py-1 backdrop-blur">
|
|
205
|
+
<Search size={12} className="shrink-0 text-zinc-600" />
|
|
206
|
+
<input
|
|
207
|
+
type="text"
|
|
208
|
+
value={searchQuery}
|
|
209
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
210
|
+
placeholder="Search nodes..."
|
|
211
|
+
aria-label="Search graph nodes"
|
|
212
|
+
className="w-full bg-transparent text-xs text-zinc-300 placeholder:text-zinc-600 outline-none"
|
|
213
|
+
/>
|
|
214
|
+
{searchQuery && (
|
|
215
|
+
<button
|
|
216
|
+
onClick={() => setSearchQuery("")}
|
|
217
|
+
className="shrink-0 text-zinc-600 hover:text-zinc-400"
|
|
218
|
+
>
|
|
219
|
+
<X size={12} />
|
|
220
|
+
</button>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{inspectorNode && (
|
|
225
|
+
<aside className="absolute bottom-3 right-3 z-10 w-72 rounded-lg border border-zinc-800 bg-zinc-950/90 p-3 text-sm text-zinc-300 backdrop-blur">
|
|
226
|
+
<p className="text-xs text-zinc-500">Selected node</p>
|
|
227
|
+
<p className="mt-1 text-sm font-medium text-zinc-100">{inspectorNode.label}</p>
|
|
228
|
+
<p className="text-xs uppercase tracking-wide text-zinc-500">{inspectorNode.type}</p>
|
|
229
|
+
<div className="mt-3 flex items-center gap-2">
|
|
230
|
+
{inspectorNode.href && (
|
|
231
|
+
<a
|
|
232
|
+
href={inspectorNode.href}
|
|
233
|
+
className="rounded-md border border-zinc-700 px-2 py-1 text-xs text-zinc-200 transition-colors hover:border-zinc-600 hover:bg-zinc-900"
|
|
234
|
+
>
|
|
235
|
+
Open node
|
|
236
|
+
</a>
|
|
237
|
+
)}
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
onClick={() => {
|
|
241
|
+
setSelectedNode(null);
|
|
242
|
+
graphViewRef.current?.clearSelection();
|
|
243
|
+
}}
|
|
244
|
+
className="rounded-md border border-zinc-800 px-2 py-1 text-xs text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200"
|
|
245
|
+
>
|
|
246
|
+
Clear
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
</aside>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
<GraphView
|
|
253
|
+
ref={graphViewRef}
|
|
254
|
+
nodes={filteredNodes}
|
|
255
|
+
edges={filteredEdges}
|
|
256
|
+
selectedNoteIds={selectedNoteIds}
|
|
257
|
+
searchQuery={searchQuery}
|
|
258
|
+
onNoteSelect={handleNoteSelect}
|
|
259
|
+
onSelectionChange={setSelectedNode}
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { GraphNode, GraphEdge } from "@/app/api/graph/route";
|
|
2
|
+
import type * as d3 from "d3";
|
|
3
|
+
|
|
4
|
+
export type { GraphNode, GraphEdge };
|
|
5
|
+
|
|
6
|
+
export const NODE_COLORS: Record<string, string> = {
|
|
7
|
+
folder: "#f59e0b",
|
|
8
|
+
note: "#7c3aed",
|
|
9
|
+
task: "#0d9488",
|
|
10
|
+
person: "#f43f5e",
|
|
11
|
+
agent: "#3b82f6",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const NODE_RADIUS: Record<string, number> = {
|
|
15
|
+
folder: 18,
|
|
16
|
+
note: 14,
|
|
17
|
+
task: 9,
|
|
18
|
+
person: 12,
|
|
19
|
+
agent: 10,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const LEGEND_LABELS: Record<string, string> = {
|
|
23
|
+
folder: "Folders",
|
|
24
|
+
note: "Notes",
|
|
25
|
+
task: "Tasks",
|
|
26
|
+
person: "People",
|
|
27
|
+
agent: "Agents",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SimNode = GraphNode & d3.SimulationNodeDatum;
|
|
31
|
+
export type SimLink = d3.SimulationLinkDatum<SimNode> & { type: GraphEdge["type"] };
|
|
32
|
+
export type GraphSelection = Pick<GraphNode, "id" | "label" | "type" | "href" | "status">;
|
|
33
|
+
|
|
34
|
+
export interface GraphViewHandle {
|
|
35
|
+
fitView: () => void;
|
|
36
|
+
clearSelection: () => void;
|
|
37
|
+
zoomToNode: (nodeId: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Props = {
|
|
41
|
+
nodes: GraphNode[];
|
|
42
|
+
edges: GraphEdge[];
|
|
43
|
+
selectedNoteIds: string[];
|
|
44
|
+
searchQuery: string;
|
|
45
|
+
onNoteSelect: (noteId: string | null) => void;
|
|
46
|
+
onSelectionChange?: (node: GraphSelection | null) => void;
|
|
47
|
+
};
|