@knotpad/app 0.1.0 → 0.1.1
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/bin/brief.js +165 -78
- package/package.json +3 -17
- package/app/(app)/calendar/page.tsx +0 -57
- package/app/(app)/error.tsx +0 -35
- package/app/(app)/graph/page.tsx +0 -32
- package/app/(app)/guide/page.tsx +0 -21
- package/app/(app)/kanban/loading.tsx +0 -24
- package/app/(app)/kanban/page.tsx +0 -59
- package/app/(app)/layout.tsx +0 -122
- package/app/(app)/list/loading.tsx +0 -21
- package/app/(app)/list/page.tsx +0 -137
- package/app/(app)/loading.tsx +0 -18
- package/app/(app)/notes/[noteId]/page.tsx +0 -84
- package/app/(app)/notes/layout.tsx +0 -30
- package/app/(app)/notes/page.tsx +0 -39
- package/app/(app)/page.tsx +0 -5
- package/app/(app)/settings/agent-token/page.tsx +0 -59
- package/app/(app)/settings/backup/page.tsx +0 -49
- package/app/(app)/settings/billing/page.tsx +0 -53
- package/app/(app)/settings/calendar/page.tsx +0 -41
- package/app/(app)/settings/layout.test.tsx +0 -39
- package/app/(app)/settings/layout.tsx +0 -71
- package/app/(app)/settings/page.tsx +0 -4
- package/app/(app)/settings/security/page.tsx +0 -43
- package/app/(app)/settings/team/page.tsx +0 -74
- package/app/(app)/settings/workspace/page.tsx +0 -27
- package/app/(app)/tasks/[taskId]/page.tsx +0 -79
- package/app/(auth)/forgot-password/page.tsx +0 -106
- package/app/(auth)/guest/page.tsx +0 -56
- package/app/(auth)/layout.tsx +0 -13
- package/app/(auth)/login/page.tsx +0 -14
- package/app/(auth)/register/page.tsx +0 -193
- package/app/(auth)/reset-password/page.tsx +0 -138
- package/app/api/account/claim/route.tsx +0 -135
- package/app/api/admin/backfill-encryption/route.tsx +0 -43
- package/app/api/admin/license/route.tsx +0 -42
- package/app/api/auth/2fa/route.tsx +0 -148
- package/app/api/auth/[...nextauth]/route.tsx +0 -3
- package/app/api/auth/change-password/route.tsx +0 -61
- package/app/api/auth/check-2fa/route.tsx +0 -19
- package/app/api/auth/forgot-password/route.tsx +0 -65
- package/app/api/auth/reset-password/route.tsx +0 -52
- package/app/api/auth/verify-2fa/route.tsx +0 -88
- package/app/api/backup/download/db/route.ts +0 -29
- package/app/api/backup/download/notes/route.ts +0 -25
- package/app/api/backup/settings/route.ts +0 -92
- package/app/api/billing/checkout/route.tsx +0 -81
- package/app/api/billing/migrate/route.tsx +0 -163
- package/app/api/billing/portal/route.tsx +0 -24
- package/app/api/billing/setup-intent/route.tsx +0 -55
- package/app/api/billing/status/route.tsx +0 -36
- package/app/api/billing/subscribe/route.tsx +0 -85
- package/app/api/billing/webhook/route.tsx +0 -199
- package/app/api/calendar-feeds/[feedId]/route.tsx +0 -67
- package/app/api/calendar-feeds/[feedId]/sync/route.tsx +0 -37
- package/app/api/calendar-feeds/events/route.tsx +0 -82
- package/app/api/calendar-feeds/route.tsx +0 -52
- package/app/api/calendar-feeds/sync-all/route.tsx +0 -34
- package/app/api/cron/calendar-feeds/route.tsx +0 -31
- package/app/api/cron/stale-tasks/route.tsx +0 -51
- package/app/api/cron/sync/route.tsx +0 -34
- package/app/api/devices/[deviceId]/route.tsx +0 -25
- package/app/api/devices/route.tsx +0 -41
- package/app/api/export/route.tsx +0 -40
- package/app/api/feedback/route.tsx +0 -54
- package/app/api/folders/[folderId]/route.tsx +0 -51
- package/app/api/folders/route.tsx +0 -37
- package/app/api/graph/route.tsx +0 -242
- package/app/api/guest/route.tsx +0 -58
- package/app/api/health/route.tsx +0 -10
- package/app/api/holidays/countries/route.tsx +0 -14
- package/app/api/holidays/route.tsx +0 -49
- package/app/api/holidays/states/route.tsx +0 -21
- package/app/api/invites/[token]/route.tsx +0 -131
- package/app/api/invites/route.tsx +0 -74
- package/app/api/mcp/generate-token/route.tsx +0 -55
- package/app/api/mcp/revoke-token/[tokenId]/route.tsx +0 -30
- package/app/api/mcp/update-alias/[tokenId]/route.tsx +0 -22
- package/app/api/notes/[noteId]/export/route.tsx +0 -45
- package/app/api/notes/[noteId]/route.tsx +0 -360
- package/app/api/notes/route.tsx +0 -112
- package/app/api/notifications/route.tsx +0 -44
- package/app/api/register/route.tsx +0 -67
- package/app/api/restore/route.tsx +0 -148
- package/app/api/sync/conflicts/[conflictId]/route.tsx +0 -134
- package/app/api/sync/conflicts/route.tsx +0 -48
- package/app/api/sync/status/route.tsx +0 -49
- package/app/api/sync/trigger/route.tsx +0 -15
- package/app/api/tasks/[taskId]/detail/route.tsx +0 -68
- package/app/api/tasks/[taskId]/route.tsx +0 -259
- package/app/api/tasks/bulk/route.tsx +0 -133
- package/app/api/tasks/route.tsx +0 -36
- package/app/api/workspace/active/route.tsx +0 -39
- package/app/api/workspace/create-team/route.tsx +0 -42
- package/app/api/workspace/kanban-statuses/route.tsx +0 -71
- package/app/api/workspace/members/[memberId]/route.tsx +0 -69
- package/app/api/workspace/route.tsx +0 -24
- package/app/download/page.tsx +0 -170
- package/app/favicon.ico +0 -0
- package/app/generated/prisma/client.d.ts +0 -1
- package/app/generated/prisma/client.js +0 -5
- package/app/generated/prisma/default.d.ts +0 -1
- package/app/generated/prisma/default.js +0 -5
- package/app/generated/prisma/edge.d.ts +0 -1
- package/app/generated/prisma/edge.js +0 -497
- package/app/generated/prisma/index-browser.js +0 -523
- package/app/generated/prisma/index.d.ts +0 -46376
- package/app/generated/prisma/index.js +0 -497
- package/app/generated/prisma/package.json +0 -144
- package/app/generated/prisma/query_compiler_fast_bg.js +0 -2
- package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +0 -2
- package/app/generated/prisma/runtime/client.d.ts +0 -3386
- package/app/generated/prisma/runtime/client.js +0 -86
- package/app/generated/prisma/runtime/index-browser.d.ts +0 -90
- package/app/generated/prisma/runtime/index-browser.js +0 -6
- package/app/generated/prisma/runtime/wasm-compiler-edge.js +0 -76
- package/app/generated/prisma/schema.prisma +0 -456
- package/app/generated/prisma/wasm-edge-light-loader.mjs +0 -5
- package/app/generated/prisma/wasm-worker-loader.mjs +0 -5
- package/app/globals.css +0 -54
- package/app/invite/[token]/page.tsx +0 -52
- package/app/layout.tsx +0 -90
- package/app/mcp/route.tsx +0 -430
- package/app/opengraph-image.tsx +0 -120
- package/app/page.tsx +0 -398
- package/app/privacy/page.tsx +0 -69
- package/app/robots.tsx +0 -25
- package/app/sitemap.tsx +0 -36
- package/app/terms/page.tsx +0 -69
- package/app/upgrade/page.tsx +0 -75
- package/auth.config.ts +0 -33
- package/auth.ts +0 -79
- package/components/auth/login-form.tsx +0 -302
- package/components/auth/password-checklist.tsx +0 -31
- package/components/auth/password-input.tsx +0 -36
- package/components/auth/switch-account-button.test.tsx +0 -22
- package/components/auth/switch-account-button.tsx +0 -19
- package/components/auth/two-factor-input.tsx +0 -116
- package/components/billing/billing-dashboard.tsx +0 -265
- package/components/billing/card-form.tsx +0 -210
- package/components/billing/claim-account-form.tsx +0 -99
- package/components/branding/app-logo.test.tsx +0 -20
- package/components/branding/app-logo.tsx +0 -25
- package/components/calendar/calendar-agenda.tsx +0 -150
- package/components/calendar/calendar-drag.test.tsx +0 -177
- package/components/calendar/calendar-grid.tsx +0 -357
- package/components/calendar/calendar-hooks.test.tsx +0 -27
- package/components/calendar/calendar-hooks.ts +0 -351
- package/components/calendar/calendar-toolbar.test.tsx +0 -68
- package/components/calendar/calendar-toolbar.tsx +0 -291
- package/components/calendar/calendar-types.ts +0 -148
- package/components/calendar/calendar-view.test.tsx +0 -295
- package/components/calendar/calendar-view.tsx +0 -307
- package/components/calendar/day-detail-popover.tsx +0 -174
- package/components/calendar/task-chip.tsx +0 -86
- package/components/command/command-palette.test.tsx +0 -33
- package/components/command/command-palette.tsx +0 -310
- package/components/download-cta.tsx +0 -87
- package/components/feedback/feedback-popup.tsx +0 -207
- package/components/graph/graph-draw.ts +0 -337
- package/components/graph/graph-overlays.tsx +0 -160
- package/components/graph/graph-page.test.tsx +0 -131
- package/components/graph/graph-page.tsx +0 -263
- package/components/graph/graph-types.ts +0 -47
- package/components/graph/graph-view.tsx +0 -322
- package/components/guide/guide-view.tsx +0 -522
- package/components/kanban/kanban-board.test.tsx +0 -128
- package/components/kanban/kanban-board.tsx +0 -361
- package/components/kanban/kanban-card-menu.tsx +0 -102
- package/components/kanban/kanban-card.tsx +0 -227
- package/components/kanban/kanban-column.tsx +0 -49
- package/components/kanban/kanban-status-context.tsx +0 -28
- package/components/landing/calendar-sandbox.test.tsx +0 -15
- package/components/landing/calendar-sandbox.tsx +0 -107
- package/components/landing/graph-sandbox.test.tsx +0 -27
- package/components/landing/graph-sandbox.tsx +0 -80
- package/components/landing/kanban-sandbox.test.tsx +0 -24
- package/components/landing/kanban-sandbox.tsx +0 -101
- package/components/landing/landing-showcase.test.tsx +0 -21
- package/components/landing/landing-showcase.tsx +0 -54
- package/components/landing/list-sandbox.tsx +0 -86
- package/components/landing/mock-workspace.ts +0 -168
- package/components/landing/notes-sandbox.test.tsx +0 -14
- package/components/landing/notes-sandbox.tsx +0 -88
- package/components/layout/app-shell.tsx +0 -83
- package/components/layout/backup-scheduler.tsx +0 -122
- package/components/layout/bottom-nav.tsx +0 -43
- package/components/layout/icon-bar.test.tsx +0 -29
- package/components/layout/icon-bar.tsx +0 -118
- package/components/layout/mobile-top-bar.tsx +0 -68
- package/components/layout/notes-panel-folder.tsx +0 -127
- package/components/layout/notes-panel-note-item.tsx +0 -140
- package/components/layout/notes-panel-task-tab.tsx +0 -63
- package/components/layout/notes-panel-types.ts +0 -44
- package/components/layout/notes-panel.tsx +0 -476
- package/components/layout/notification-bell.tsx +0 -251
- package/components/layout/paywall-screen.tsx +0 -41
- package/components/layout/pro-banner.tsx +0 -76
- package/components/layout/sw-register.tsx +0 -27
- package/components/layout/workspace-switcher.tsx +0 -90
- package/components/notes/mobile-bottom-sheet.tsx +0 -99
- package/components/notes/note-editor-context-menu.tsx +0 -47
- package/components/notes/note-editor-dom.ts +0 -33
- package/components/notes/note-editor-dropdowns.tsx +0 -484
- package/components/notes/note-editor-hooks.ts +0 -692
- package/components/notes/note-editor-keyboard.ts +0 -305
- package/components/notes/note-editor-overlay.tsx +0 -90
- package/components/notes/note-editor.test.tsx +0 -372
- package/components/notes/note-editor.tsx +0 -662
- package/components/notes/note-preview-pane.tsx +0 -156
- package/components/notes/note-tabs.tsx +0 -120
- package/components/notes/note-types.tsx +0 -157
- package/components/settings/accept-invite.tsx +0 -108
- package/components/settings/agent-token-settings.tsx +0 -369
- package/components/settings/backup-restore-settings.test.tsx +0 -25
- package/components/settings/backup-restore-settings.tsx +0 -327
- package/components/settings/calendar-feeds-settings.tsx +0 -489
- package/components/settings/calendar-general-settings.tsx +0 -174
- package/components/settings/confirm-danger-action.test.tsx +0 -215
- package/components/settings/confirm-danger-action.tsx +0 -65
- package/components/settings/security-settings.tsx +0 -252
- package/components/settings/settings-guidance.test.tsx +0 -98
- package/components/settings/team-settings.tsx +0 -319
- package/components/settings/two-factor-auth.tsx +0 -296
- package/components/settings/workspace-settings-client.tsx +0 -363
- package/components/settings/workspace-settings-form.tsx +0 -73
- package/components/sync/conflict-viewer.tsx +0 -247
- package/components/sync/sync-indicator.tsx +0 -171
- package/components/tasks/snippet-thread.tsx +0 -119
- package/components/tasks/status-dot.tsx +0 -47
- package/components/tasks/task-badge.tsx +0 -43
- package/components/tasks/task-detail.test.tsx +0 -187
- package/components/tasks/task-detail.tsx +0 -458
- package/components/tasks/task-list-filters.test.tsx +0 -75
- package/components/tasks/task-list-filters.tsx +0 -163
- package/components/tasks/task-list-types.ts +0 -20
- package/components/tasks/task-list.test.tsx +0 -175
- package/components/tasks/task-list.tsx +0 -481
- package/components/tasks/task-row.tsx +0 -85
- package/components/tasks/task-table-row.tsx +0 -259
- package/components/ui/skeleton.tsx +0 -3
- package/components/ui/toast.test.tsx +0 -42
- package/components/ui/toast.tsx +0 -70
- package/electron/main.ts +0 -251
- package/electron/preload.ts +0 -56
- package/instrumentation.tsx +0 -23
- package/lib/api-error.ts +0 -50
- package/lib/backup/backup-runner.test.ts +0 -32
- package/lib/backup/backup-runner.ts +0 -19
- package/lib/backup/backup-schedule.test.ts +0 -23
- package/lib/backup/backup-schedule.ts +0 -55
- package/lib/backup/backup-settings.test.ts +0 -30
- package/lib/backup/backup-settings.ts +0 -27
- package/lib/backup/export-notes-zip.test.ts +0 -26
- package/lib/backup/export-notes-zip.ts +0 -82
- package/lib/backup/export-workspace-backup.test.ts +0 -17
- package/lib/backup/export-workspace-backup.ts +0 -77
- package/lib/backup/restore-workspace-from-export.test.ts +0 -18
- package/lib/backup/restore-workspace-from-export.ts +0 -183
- package/lib/backup/types.ts +0 -14
- package/lib/brand-icons.ts +0 -1
- package/lib/calendar-feed-crypto.ts +0 -38
- package/lib/calendar-feed.ts +0 -239
- package/lib/client/online-status.ts +0 -47
- package/lib/conflict-resolver.test.ts +0 -57
- package/lib/conflict-resolver.ts +0 -240
- package/lib/db-init.ts +0 -79
- package/lib/email.ts +0 -159
- package/lib/encryption.test.ts +0 -41
- package/lib/encryption.ts +0 -98
- package/lib/extract-snippet.test.ts +0 -123
- package/lib/extract-snippet.ts +0 -69
- package/lib/kanban-status.ts +0 -55
- package/lib/license.ts +0 -21
- package/lib/limits.ts +0 -31
- package/lib/mcp-auth.test.ts +0 -58
- package/lib/mcp-auth.ts +0 -65
- package/lib/mcp-contract.test.ts +0 -25
- package/lib/mcp-contract.ts +0 -210
- package/lib/mcp-handler.ts +0 -31
- package/lib/mcp-url.test.ts +0 -12
- package/lib/mcp-url.ts +0 -7
- package/lib/mentions.test.ts +0 -45
- package/lib/mentions.ts +0 -73
- package/lib/note-crypto.ts +0 -108
- package/lib/note-sync.ts +0 -201
- package/lib/note-title.ts +0 -93
- package/lib/prisma.ts +0 -193
- package/lib/pro-flush.ts +0 -292
- package/lib/rate-limit.ts +0 -57
- package/lib/stripe.ts +0 -38
- package/lib/sync-worker.ts +0 -388
- package/lib/task-parser.test.ts +0 -91
- package/lib/task-parser.ts +0 -81
- package/lib/task-utils.ts +0 -52
- package/lib/use-is-electron.ts +0 -19
- package/lib/use-is-mobile.ts +0 -22
- package/lib/validation/calendar-feed.ts +0 -31
- package/lib/validation/note.ts +0 -27
- package/lib/validation/task.ts +0 -26
- package/lib/view-preferences.test.ts +0 -54
- package/lib/view-preferences.ts +0 -28
- package/lib/workspace.ts +0 -66
- package/next.config.ts +0 -21
- package/postcss.config.mjs +0 -7
- package/prisma/migrations/20260519021916_init/migration.sql +0 -388
- package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +0 -8
- package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +0 -2
- package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +0 -12
- package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +0 -3
- package/prisma/migrations/20260529030000_add_folders/migration.sql +0 -17
- package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +0 -31
- package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +0 -5
- package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +0 -14
- package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +0 -26
- package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +0 -23
- package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +0 -43
- package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +0 -2
- package/prisma/migrations/20260611050000_add_task_priority/migration.sql +0 -14
- package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +0 -2
- package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +0 -25
- package/prisma/migrations/20260614160000_add_feedback/migration.sql +0 -20
- package/prisma/migrations/20260614210000_add_2fa/migration.sql +0 -4
- package/prisma/migrations/migration_lock.toml +0 -3
- package/prisma/schema.prisma +0 -457
- package/public/Logo_icon.svg +0 -1
- package/public/file.svg +0 -1
- package/public/globe.svg +0 -1
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +0 -4
- package/public/icon_dark.svg +0 -1
- package/public/knotpad_icon.svg +0 -1
- package/public/knotpad_logo_full.svg +0 -1
- package/public/manifest.json +0 -14
- package/public/next.svg +0 -1
- package/public/sw.js +0 -137
- package/public/vercel.svg +0 -1
- package/public/window.svg +0 -1
- package/tsconfig.json +0 -35
|
@@ -1,361 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useRef, useEffect } from "react";
|
|
4
|
-
import { useRouter } from "next/navigation";
|
|
5
|
-
import {
|
|
6
|
-
DndContext,
|
|
7
|
-
DragEndEvent,
|
|
8
|
-
DragOverEvent,
|
|
9
|
-
DragOverlay,
|
|
10
|
-
DragStartEvent,
|
|
11
|
-
PointerSensor,
|
|
12
|
-
TouchSensor,
|
|
13
|
-
useSensor,
|
|
14
|
-
useSensors,
|
|
15
|
-
pointerWithin,
|
|
16
|
-
rectIntersection,
|
|
17
|
-
} from "@dnd-kit/core";
|
|
18
|
-
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
19
|
-
import { KanbanColumn } from "./kanban-column";
|
|
20
|
-
import { KanbanCard } from "./kanban-card";
|
|
21
|
-
import { useKanbanStatuses } from "./kanban-status-context";
|
|
22
|
-
import { loadPreference, savePreference } from "@/lib/view-preferences";
|
|
23
|
-
import { isOverdue } from "@/lib/task-utils";
|
|
24
|
-
|
|
25
|
-
export type KanbanTask = {
|
|
26
|
-
id: string;
|
|
27
|
-
title: string;
|
|
28
|
-
status: string;
|
|
29
|
-
claimedBy: string | null;
|
|
30
|
-
claimedByAlias: string | null;
|
|
31
|
-
lastHeartbeat: string | null;
|
|
32
|
-
assigneeType: "HUMAN" | "AGENT";
|
|
33
|
-
assignee: { id: string; name: string | null; email: string | null } | null;
|
|
34
|
-
note: { id: string; title: string; folder?: { id: string; name: string } | null };
|
|
35
|
-
dueDate: string | null;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
type Props = { tasks: KanbanTask[]; filterNoteId?: string; filterFolderId?: string };
|
|
39
|
-
|
|
40
|
-
type BoardFilter = "all" | "overdue" | "agent";
|
|
41
|
-
|
|
42
|
-
export function KanbanBoard({ tasks: initialTasks, filterNoteId, filterFolderId }: Props) {
|
|
43
|
-
const router = useRouter();
|
|
44
|
-
const [tasks, setTasks] = useState(initialTasks);
|
|
45
|
-
const [activeTask, setActiveTask] = useState<KanbanTask | null>(null);
|
|
46
|
-
const [density, setDensity] = useState<"comfortable" | "compact">(() =>
|
|
47
|
-
loadPreference("brief:kanban-density", "comfortable")
|
|
48
|
-
);
|
|
49
|
-
const [boardFilter, setBoardFilter] = useState<BoardFilter>(() =>
|
|
50
|
-
loadPreference("brief:kanban-filter", "all")
|
|
51
|
-
);
|
|
52
|
-
// Track the status before drag starts so we can revert on bad drops or errors.
|
|
53
|
-
const preDragStatusRef = useRef<string | null>(null);
|
|
54
|
-
// True while a drag (or its pending PATCH) is in flight. Used to avoid
|
|
55
|
-
// re-syncing from stale `initialTasks` and snapping the card back to its
|
|
56
|
-
// pre-drag column before the server refresh lands.
|
|
57
|
-
const isDraggingRef = useRef(false);
|
|
58
|
-
const kanbanStatuses = useKanbanStatuses();
|
|
59
|
-
const visibleColumns = kanbanStatuses.filter((s) => s.isVisible);
|
|
60
|
-
|
|
61
|
-
// Sync server-filtered tasks when props change, but never interrupt an active drag.
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
if (!isDraggingRef.current) setTasks(initialTasks);
|
|
64
|
-
}, [initialTasks]);
|
|
65
|
-
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
savePreference("brief:kanban-density", density);
|
|
68
|
-
}, [density]);
|
|
69
|
-
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
savePreference("brief:kanban-filter", boardFilter);
|
|
72
|
-
}, [boardFilter]);
|
|
73
|
-
|
|
74
|
-
const sensors = useSensors(
|
|
75
|
-
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
|
76
|
-
// Press-and-hold to "pick up" a card on touch. A generous tolerance means
|
|
77
|
-
// natural finger drift during the hold doesn't cancel the pickup and fall
|
|
78
|
-
// through to a tap (which opens the task instead of dragging it).
|
|
79
|
-
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 25 } })
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
const filtered = filterNoteId ? tasks.filter((t) => t.note.id === filterNoteId) : tasks;
|
|
83
|
-
const filteredTasks = filtered.filter((task) => {
|
|
84
|
-
if (boardFilter === "overdue") return isOverdue(task);
|
|
85
|
-
if (boardFilter === "agent") return task.assigneeType === "AGENT";
|
|
86
|
-
return true;
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// Collect orphaned tasks (status not in any visible column)
|
|
90
|
-
const visibleColumnKeys = new Set(visibleColumns.map((c) => c.key));
|
|
91
|
-
const orphanedTasks = filteredTasks.filter((t) => !visibleColumnKeys.has(t.status));
|
|
92
|
-
|
|
93
|
-
function handleDragStart({ active }: DragStartEvent) {
|
|
94
|
-
const task = tasks.find((t) => t.id === active.id) ?? null;
|
|
95
|
-
setActiveTask(task);
|
|
96
|
-
preDragStatusRef.current = task?.status ?? null;
|
|
97
|
-
isDraggingRef.current = true;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// over.id can be a column ID or a card ID (when hovering over a card).
|
|
101
|
-
// If it's a card ID, infer the target column from that card's current status.
|
|
102
|
-
function resolveColumn(overId: string): string | undefined {
|
|
103
|
-
return (
|
|
104
|
-
visibleColumns.find((c) => c.key === overId)?.key ??
|
|
105
|
-
tasks.find((t) => t.id === overId)?.status
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function handleDragOver({ active, over }: DragOverEvent) {
|
|
110
|
-
if (!over) return;
|
|
111
|
-
const columnId = resolveColumn(over.id as string);
|
|
112
|
-
if (!columnId) return;
|
|
113
|
-
|
|
114
|
-
setTasks((prev) =>
|
|
115
|
-
prev.map((t) => (t.id === active.id ? { ...t, status: columnId } : t))
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Reusable function to move a task to a new status. Used by both drag-end
|
|
120
|
-
// and the context-menu "Move to status" action. Optimistic with revert on failure.
|
|
121
|
-
// `prevStatus` is the status to compare against (the task's status *before* the
|
|
122
|
-
// move). It defaults to the current state value, but callers that have already
|
|
123
|
-
// applied an optimistic update must pass the original status explicitly —
|
|
124
|
-
// otherwise the current-state read will already equal `newStatus` and the
|
|
125
|
-
// PATCH will be skipped.
|
|
126
|
-
async function moveTask(taskId: string, newStatus: string, prevStatus?: string) {
|
|
127
|
-
const currentState = tasks.find((t) => t.id === taskId)?.status;
|
|
128
|
-
const originalStatus = prevStatus ?? currentState;
|
|
129
|
-
if (!originalStatus || newStatus === originalStatus) return;
|
|
130
|
-
|
|
131
|
-
// Optimistic update (idempotent — caller may have already applied it)
|
|
132
|
-
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
|
|
133
|
-
|
|
134
|
-
try {
|
|
135
|
-
const res = await fetch(`/api/tasks/${taskId}`, {
|
|
136
|
-
method: "PATCH",
|
|
137
|
-
headers: { "Content-Type": "application/json" },
|
|
138
|
-
body: JSON.stringify({ status: newStatus }),
|
|
139
|
-
});
|
|
140
|
-
if (!res.ok) throw new Error("patch failed");
|
|
141
|
-
router.refresh();
|
|
142
|
-
} catch {
|
|
143
|
-
// Revert optimistic update on failure.
|
|
144
|
-
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: originalStatus } : t)));
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async function handleDragEnd({ active, over }: DragEndEvent) {
|
|
149
|
-
setActiveTask(null);
|
|
150
|
-
|
|
151
|
-
const columnId = over ? resolveColumn(over.id as string) : undefined;
|
|
152
|
-
|
|
153
|
-
// Dropped outside any valid target — revert to original status.
|
|
154
|
-
if (!columnId) {
|
|
155
|
-
if (preDragStatusRef.current) {
|
|
156
|
-
setTasks((prev) =>
|
|
157
|
-
prev.map((t) =>
|
|
158
|
-
t.id === active.id ? { ...t, status: preDragStatusRef.current! } : t
|
|
159
|
-
)
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
preDragStatusRef.current = null;
|
|
163
|
-
isDraggingRef.current = false;
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
// Capture the pre-drag status BEFORE resetting the ref, so moveTask can
|
|
167
|
-
// compare against the *original* column (not the one dragOver already set).
|
|
168
|
-
const prevStatus = preDragStatusRef.current;
|
|
169
|
-
preDragStatusRef.current = null;
|
|
170
|
-
|
|
171
|
-
// Apply the resolved column immediately — don't rely on dragOver having
|
|
172
|
-
// already fired (touch drags can drop before a final dragOver event).
|
|
173
|
-
setTasks((prev) =>
|
|
174
|
-
prev.map((t) => (t.id === active.id ? { ...t, status: columnId } : t))
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
isDraggingRef.current = false;
|
|
178
|
-
// Pass the pre-drag status so moveTask compares against the *original*
|
|
179
|
-
// column, not the one handleDragOver already applied optimistically.
|
|
180
|
-
await moveTask(active.id as string, columnId, prevStatus ?? undefined);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (filtered.length === 0) {
|
|
184
|
-
return (
|
|
185
|
-
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-center">
|
|
186
|
-
<p className="text-sm font-medium text-zinc-500">No tasks yet</p>
|
|
187
|
-
<p className="text-xs text-zinc-700 max-w-xs">
|
|
188
|
-
Write a note and add <code className="font-mono text-zinc-500">- [ ] Task description</code> to create tasks.
|
|
189
|
-
</p>
|
|
190
|
-
</div>
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const boardScopeLabel = filterNoteId
|
|
195
|
-
? "Filtered to one note"
|
|
196
|
-
: filterFolderId
|
|
197
|
-
? "Filtered to one folder"
|
|
198
|
-
: "All visible tasks";
|
|
199
|
-
|
|
200
|
-
return (
|
|
201
|
-
<DndContext
|
|
202
|
-
sensors={sensors}
|
|
203
|
-
collisionDetection={(args) => {
|
|
204
|
-
const hits = pointerWithin(args);
|
|
205
|
-
return hits.length > 0 ? hits : rectIntersection(args);
|
|
206
|
-
}}
|
|
207
|
-
autoScroll={{ threshold: { x: 0.1, y: 0.1 } }}
|
|
208
|
-
onDragStart={handleDragStart}
|
|
209
|
-
onDragOver={handleDragOver}
|
|
210
|
-
onDragEnd={handleDragEnd}
|
|
211
|
-
>
|
|
212
|
-
<div className="flex h-full min-h-0 flex-col gap-3 p-3 md:p-4">
|
|
213
|
-
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-800 bg-zinc-950/60 px-3 py-2">
|
|
214
|
-
<p className="text-xs text-zinc-500">{boardScopeLabel}</p>
|
|
215
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
216
|
-
<div
|
|
217
|
-
aria-label="Quick filters"
|
|
218
|
-
className="inline-flex items-center rounded-md border border-zinc-800 bg-zinc-950 p-1"
|
|
219
|
-
>
|
|
220
|
-
{[
|
|
221
|
-
{ key: "all", label: "All" },
|
|
222
|
-
{ key: "overdue", label: "Overdue" },
|
|
223
|
-
{ key: "agent", label: "Agent" },
|
|
224
|
-
].map((filterOption) => (
|
|
225
|
-
<button
|
|
226
|
-
key={filterOption.key}
|
|
227
|
-
type="button"
|
|
228
|
-
aria-pressed={boardFilter === filterOption.key}
|
|
229
|
-
onClick={() => setBoardFilter(filterOption.key as BoardFilter)}
|
|
230
|
-
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
231
|
-
boardFilter === filterOption.key
|
|
232
|
-
? "bg-zinc-800 text-zinc-200"
|
|
233
|
-
: "text-zinc-500 hover:text-zinc-300"
|
|
234
|
-
}`}
|
|
235
|
-
>
|
|
236
|
-
{filterOption.label}
|
|
237
|
-
</button>
|
|
238
|
-
))}
|
|
239
|
-
</div>
|
|
240
|
-
<div
|
|
241
|
-
aria-label="Card density"
|
|
242
|
-
className="inline-flex items-center rounded-md border border-zinc-800 bg-zinc-950 p-1"
|
|
243
|
-
>
|
|
244
|
-
<button
|
|
245
|
-
type="button"
|
|
246
|
-
aria-pressed={density === "comfortable"}
|
|
247
|
-
onClick={() => setDensity("comfortable")}
|
|
248
|
-
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
249
|
-
density === "comfortable"
|
|
250
|
-
? "bg-zinc-800 text-zinc-200"
|
|
251
|
-
: "text-zinc-500 hover:text-zinc-300"
|
|
252
|
-
}`}
|
|
253
|
-
>
|
|
254
|
-
Comfortable
|
|
255
|
-
</button>
|
|
256
|
-
<button
|
|
257
|
-
type="button"
|
|
258
|
-
aria-pressed={density === "compact"}
|
|
259
|
-
onClick={() => setDensity("compact")}
|
|
260
|
-
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
261
|
-
density === "compact"
|
|
262
|
-
? "bg-zinc-800 text-zinc-200"
|
|
263
|
-
: "text-zinc-500 hover:text-zinc-300"
|
|
264
|
-
}`}
|
|
265
|
-
>
|
|
266
|
-
Compact
|
|
267
|
-
</button>
|
|
268
|
-
</div>
|
|
269
|
-
</div>
|
|
270
|
-
</div>
|
|
271
|
-
|
|
272
|
-
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto md:flex-row md:overflow-x-auto md:overflow-y-hidden">
|
|
273
|
-
{visibleColumns.map((col) => {
|
|
274
|
-
const colTasks = filteredTasks.filter((t) => t.status === col.key);
|
|
275
|
-
return (
|
|
276
|
-
<SortableContext
|
|
277
|
-
key={col.key}
|
|
278
|
-
id={col.key}
|
|
279
|
-
items={colTasks.map((t) => t.id)}
|
|
280
|
-
strategy={verticalListSortingStrategy}
|
|
281
|
-
>
|
|
282
|
-
<KanbanColumn
|
|
283
|
-
id={col.key}
|
|
284
|
-
label={col.label}
|
|
285
|
-
color={col.color}
|
|
286
|
-
count={colTasks.length}
|
|
287
|
-
density={density}
|
|
288
|
-
isFilteredEmpty={boardFilter !== "all" && colTasks.length === 0}
|
|
289
|
-
>
|
|
290
|
-
{colTasks.map((task) => (
|
|
291
|
-
<KanbanCard
|
|
292
|
-
key={task.id}
|
|
293
|
-
task={task}
|
|
294
|
-
onMoveTask={moveTask}
|
|
295
|
-
backHref={
|
|
296
|
-
filterNoteId
|
|
297
|
-
? `/kanban?note=${filterNoteId}`
|
|
298
|
-
: filterFolderId
|
|
299
|
-
? `/kanban?folder=${filterFolderId}`
|
|
300
|
-
: "/kanban"
|
|
301
|
-
}
|
|
302
|
-
/>
|
|
303
|
-
))}
|
|
304
|
-
</KanbanColumn>
|
|
305
|
-
</SortableContext>
|
|
306
|
-
);
|
|
307
|
-
})}
|
|
308
|
-
|
|
309
|
-
{/* Orphaned tasks column - tasks with status not in visible columns */}
|
|
310
|
-
{orphanedTasks.length > 0 && (
|
|
311
|
-
<SortableContext
|
|
312
|
-
key="orphaned"
|
|
313
|
-
id="orphaned"
|
|
314
|
-
items={orphanedTasks.map((t) => t.id)}
|
|
315
|
-
strategy={verticalListSortingStrategy}
|
|
316
|
-
>
|
|
317
|
-
<KanbanColumn
|
|
318
|
-
id="orphaned"
|
|
319
|
-
label="Uncategorized"
|
|
320
|
-
color="border-zinc-600"
|
|
321
|
-
count={orphanedTasks.length}
|
|
322
|
-
density={density}
|
|
323
|
-
>
|
|
324
|
-
{orphanedTasks.map((task) => (
|
|
325
|
-
<KanbanCard
|
|
326
|
-
key={task.id}
|
|
327
|
-
task={task}
|
|
328
|
-
onMoveTask={moveTask}
|
|
329
|
-
backHref={
|
|
330
|
-
filterNoteId
|
|
331
|
-
? `/kanban?note=${filterNoteId}`
|
|
332
|
-
: filterFolderId
|
|
333
|
-
? `/kanban?folder=${filterFolderId}`
|
|
334
|
-
: "/kanban"
|
|
335
|
-
}
|
|
336
|
-
/>
|
|
337
|
-
))}
|
|
338
|
-
</KanbanColumn>
|
|
339
|
-
</SortableContext>
|
|
340
|
-
)}
|
|
341
|
-
</div>
|
|
342
|
-
</div>
|
|
343
|
-
|
|
344
|
-
<DragOverlay>
|
|
345
|
-
{activeTask && (
|
|
346
|
-
<KanbanCard
|
|
347
|
-
task={activeTask}
|
|
348
|
-
isDragging
|
|
349
|
-
backHref={
|
|
350
|
-
filterNoteId
|
|
351
|
-
? `/kanban?note=${filterNoteId}`
|
|
352
|
-
: filterFolderId
|
|
353
|
-
? `/kanban?folder=${filterFolderId}`
|
|
354
|
-
: "/kanban"
|
|
355
|
-
}
|
|
356
|
-
/>
|
|
357
|
-
)}
|
|
358
|
-
</DragOverlay>
|
|
359
|
-
</DndContext>
|
|
360
|
-
);
|
|
361
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import React, { forwardRef } from "react";
|
|
4
|
-
import type { KanbanTask } from "./kanban-board";
|
|
5
|
-
import type { KanbanStatusConfig } from "@/lib/kanban-status";
|
|
6
|
-
|
|
7
|
-
// Map status border-color classes to a dot color class for the menu indicator.
|
|
8
|
-
const DOT_COLORS: Record<string, string> = {
|
|
9
|
-
"border-zinc-700": "bg-zinc-500",
|
|
10
|
-
"border-violet-700": "bg-violet-500",
|
|
11
|
-
"border-blue-700": "bg-blue-500",
|
|
12
|
-
"border-amber-700": "bg-amber-500",
|
|
13
|
-
"border-emerald-700": "bg-emerald-500",
|
|
14
|
-
"border-zinc-600": "bg-zinc-400",
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
type MenuProps = {
|
|
18
|
-
task: KanbanTask;
|
|
19
|
-
statuses: KanbanStatusConfig[];
|
|
20
|
-
onSelect: (statusKey: string) => void;
|
|
21
|
-
onClose: () => void;
|
|
22
|
-
/** When true, renders without the floating wrapper (for MobileBottomSheet). */
|
|
23
|
-
mobile?: boolean;
|
|
24
|
-
/** Position for the desktop floating menu. */
|
|
25
|
-
pos?: { top: number; left: number };
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
function StatusList({
|
|
29
|
-
task,
|
|
30
|
-
statuses,
|
|
31
|
-
onSelect,
|
|
32
|
-
onClose,
|
|
33
|
-
}: Pick<MenuProps, "task" | "statuses" | "onSelect" | "onClose">) {
|
|
34
|
-
return (
|
|
35
|
-
<div className="flex flex-col">
|
|
36
|
-
{statuses.map((s) => {
|
|
37
|
-
const isCurrent = s.key === task.status;
|
|
38
|
-
const dotColor = DOT_COLORS[s.color] ?? "bg-zinc-500";
|
|
39
|
-
return (
|
|
40
|
-
<button
|
|
41
|
-
key={s.key}
|
|
42
|
-
type="button"
|
|
43
|
-
disabled={isCurrent}
|
|
44
|
-
onMouseDown={(e) => {
|
|
45
|
-
// Use mousedown so the menu closes before the outside-click handler fires
|
|
46
|
-
e.preventDefault();
|
|
47
|
-
if (!isCurrent) {
|
|
48
|
-
onSelect(s.key);
|
|
49
|
-
onClose();
|
|
50
|
-
}
|
|
51
|
-
}}
|
|
52
|
-
onTouchEnd={(e) => {
|
|
53
|
-
e.preventDefault();
|
|
54
|
-
if (!isCurrent) {
|
|
55
|
-
onSelect(s.key);
|
|
56
|
-
onClose();
|
|
57
|
-
}
|
|
58
|
-
}}
|
|
59
|
-
className={`flex items-center gap-2.5 px-3 py-2 text-sm text-left transition-colors ${
|
|
60
|
-
isCurrent
|
|
61
|
-
? "text-zinc-500 cursor-default"
|
|
62
|
-
: "text-zinc-300 hover:bg-zinc-800 active:bg-zinc-700"
|
|
63
|
-
}`}
|
|
64
|
-
>
|
|
65
|
-
<span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} aria-hidden />
|
|
66
|
-
<span className="flex-1 truncate">{s.label}</span>
|
|
67
|
-
{isCurrent && (
|
|
68
|
-
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden className="text-zinc-500 shrink-0">
|
|
69
|
-
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
|
|
70
|
-
</svg>
|
|
71
|
-
)}
|
|
72
|
-
</button>
|
|
73
|
-
);
|
|
74
|
-
})}
|
|
75
|
-
</div>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Status picker menu for kanban cards.
|
|
81
|
-
* Desktop: rendered as a fixed-position floating menu.
|
|
82
|
-
* Mobile: rendered inline inside a MobileBottomSheet (pass `mobile` prop).
|
|
83
|
-
*/
|
|
84
|
-
export const KanbanCardMenu = forwardRef<HTMLDivElement, MenuProps>(
|
|
85
|
-
function KanbanCardMenu({ task, statuses, onSelect, onClose, mobile, pos }, ref) {
|
|
86
|
-
if (mobile) {
|
|
87
|
-
return (
|
|
88
|
-
<StatusList task={task} statuses={statuses} onSelect={onSelect} onClose={onClose} />
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return (
|
|
93
|
-
<div
|
|
94
|
-
ref={ref}
|
|
95
|
-
className="fixed z-[70] w-44 rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-xl"
|
|
96
|
-
style={pos ? { top: pos.top, left: pos.left } : undefined}
|
|
97
|
-
>
|
|
98
|
-
<StatusList task={task} statuses={statuses} onSelect={onSelect} onClose={onClose} />
|
|
99
|
-
</div>
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
);
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useRef, useEffect } from "react";
|
|
4
|
-
import { useSortable } from "@dnd-kit/sortable";
|
|
5
|
-
import { CSS } from "@dnd-kit/utilities";
|
|
6
|
-
import { useRouter } from "next/navigation";
|
|
7
|
-
import type { KanbanTask } from "./kanban-board";
|
|
8
|
-
import { isOverdue } from "@/lib/task-utils";
|
|
9
|
-
import { useIsMobile } from "@/lib/use-is-mobile";
|
|
10
|
-
import { useKanbanStatuses } from "./kanban-status-context";
|
|
11
|
-
import { KanbanCardMenu } from "./kanban-card-menu";
|
|
12
|
-
import { MobileBottomSheet } from "@/components/notes/mobile-bottom-sheet";
|
|
13
|
-
import { createPortal } from "react-dom";
|
|
14
|
-
|
|
15
|
-
type Props = {
|
|
16
|
-
task: KanbanTask;
|
|
17
|
-
isDragging?: boolean;
|
|
18
|
-
backHref: string;
|
|
19
|
-
onMoveTask?: (taskId: string, newStatus: string) => void;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export function KanbanCard({ task, isDragging, backHref, onMoveTask }: Props) {
|
|
23
|
-
const router = useRouter();
|
|
24
|
-
const isMobile = useIsMobile();
|
|
25
|
-
const statuses = useKanbanStatuses();
|
|
26
|
-
const { attributes, listeners, setNodeRef, transform, transition, isDragging: isSortableDragging } =
|
|
27
|
-
useSortable({ id: task.id });
|
|
28
|
-
|
|
29
|
-
const [menuOpen, setMenuOpen] = useState(false);
|
|
30
|
-
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
|
31
|
-
const menuRef = useRef<HTMLDivElement>(null);
|
|
32
|
-
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
33
|
-
|
|
34
|
-
const style = {
|
|
35
|
-
transform: CSS.Transform.toString(transform),
|
|
36
|
-
transition,
|
|
37
|
-
opacity: isSortableDragging ? 0.4 : 1,
|
|
38
|
-
touchAction: "none" as const,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const assigneeLabel =
|
|
42
|
-
task.assigneeType === "AGENT"
|
|
43
|
-
? task.claimedByAlias
|
|
44
|
-
? `@agent:${task.claimedByAlias}`
|
|
45
|
-
: "@agent"
|
|
46
|
-
: task.assignee?.name
|
|
47
|
-
? `@${task.assignee.name}`
|
|
48
|
-
: null;
|
|
49
|
-
|
|
50
|
-
// Stale if claimed and no heartbeat for >30 minutes
|
|
51
|
-
const isStale =
|
|
52
|
-
task.claimedBy != null &&
|
|
53
|
-
task.lastHeartbeat != null &&
|
|
54
|
-
Date.now() - new Date(task.lastHeartbeat).getTime() > 30 * 60 * 1000;
|
|
55
|
-
|
|
56
|
-
const taskIsOverdue = isOverdue(task);
|
|
57
|
-
const formattedDueDate = task.dueDate
|
|
58
|
-
? new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
|
59
|
-
: null;
|
|
60
|
-
|
|
61
|
-
// Dismiss floating menu on outside click or Escape
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
if (!menuOpen || isMobile) return;
|
|
64
|
-
function onMouseDown(e: MouseEvent) {
|
|
65
|
-
if (
|
|
66
|
-
menuRef.current && !menuRef.current.contains(e.target as Node) &&
|
|
67
|
-
buttonRef.current && !buttonRef.current.contains(e.target as Node)
|
|
68
|
-
) {
|
|
69
|
-
setMenuOpen(false);
|
|
70
|
-
setMenuPos(null);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
function onKeyDown(e: KeyboardEvent) {
|
|
74
|
-
if (e.key === "Escape") { setMenuOpen(false); setMenuPos(null); }
|
|
75
|
-
}
|
|
76
|
-
document.addEventListener("mousedown", onMouseDown);
|
|
77
|
-
document.addEventListener("keydown", onKeyDown);
|
|
78
|
-
return () => { document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("keydown", onKeyDown); };
|
|
79
|
-
}, [menuOpen, isMobile]);
|
|
80
|
-
|
|
81
|
-
function handleMenuButton(e: React.MouseEvent) {
|
|
82
|
-
e.stopPropagation();
|
|
83
|
-
e.preventDefault();
|
|
84
|
-
if (menuOpen) { setMenuOpen(false); setMenuPos(null); return; }
|
|
85
|
-
if (isMobile) {
|
|
86
|
-
setMenuOpen(true);
|
|
87
|
-
} else {
|
|
88
|
-
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
89
|
-
setMenuPos({ top: rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - 200) });
|
|
90
|
-
setMenuOpen(true);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function handleContextMenu(e: React.MouseEvent) {
|
|
95
|
-
e.preventDefault();
|
|
96
|
-
e.stopPropagation();
|
|
97
|
-
if (isMobile) return; // long-press not needed on mobile (we have the button)
|
|
98
|
-
setMenuPos({ top: e.clientY, left: Math.min(e.clientX, window.innerWidth - 200) });
|
|
99
|
-
setMenuOpen(true);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function handleSelectStatus(statusKey: string) {
|
|
103
|
-
setMenuOpen(false);
|
|
104
|
-
setMenuPos(null);
|
|
105
|
-
if (statusKey !== task.status && onMoveTask) {
|
|
106
|
-
onMoveTask(task.id, statusKey);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function handleCardClick(e: React.MouseEvent) {
|
|
111
|
-
// Don't navigate if clicking the menu button or menu itself
|
|
112
|
-
if (buttonRef.current?.contains(e.target as Node)) return;
|
|
113
|
-
if (menuRef.current?.contains(e.target as Node)) return;
|
|
114
|
-
router.push(`/tasks/${task.id}?from=${encodeURIComponent(backHref)}`);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function handleCardKeyDown(e: React.KeyboardEvent) {
|
|
118
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
119
|
-
e.preventDefault();
|
|
120
|
-
router.push(`/tasks/${task.id}?from=${encodeURIComponent(backHref)}`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return (
|
|
125
|
-
<div
|
|
126
|
-
ref={setNodeRef}
|
|
127
|
-
style={style}
|
|
128
|
-
{...attributes}
|
|
129
|
-
{...listeners}
|
|
130
|
-
data-kanban-card
|
|
131
|
-
onContextMenu={handleContextMenu}
|
|
132
|
-
className={`group relative rounded-md border p-2.5 cursor-grab active:cursor-grabbing select-none [-webkit-touch-callout:none] [-webkit-user-select:none] transition-[transform,box-shadow,border-color] duration-150 active:scale-[1.02] active:border-zinc-500 active:shadow-lg active:shadow-black/30 ${
|
|
133
|
-
taskIsOverdue
|
|
134
|
-
? "border-red-800/60 bg-red-950/40"
|
|
135
|
-
: "border-zinc-700 bg-zinc-900"
|
|
136
|
-
} ${isDragging ? "shadow-lg shadow-black/40 rotate-1" : "hover:border-zinc-600"}`}
|
|
137
|
-
>
|
|
138
|
-
{/* Card body — click navigates to task */}
|
|
139
|
-
<div
|
|
140
|
-
role="link"
|
|
141
|
-
tabIndex={0}
|
|
142
|
-
onClick={handleCardClick}
|
|
143
|
-
onKeyDown={handleCardKeyDown}
|
|
144
|
-
draggable={false}
|
|
145
|
-
className="block pr-6"
|
|
146
|
-
>
|
|
147
|
-
<div className="flex items-start gap-1.5">
|
|
148
|
-
<p className="flex-1 text-sm font-medium leading-snug text-zinc-100 line-clamp-2">
|
|
149
|
-
{task.title}
|
|
150
|
-
</p>
|
|
151
|
-
{taskIsOverdue && <span title="Overdue" className="shrink-0 text-red-400 text-xs">!</span>}
|
|
152
|
-
{isStale && <span title="No heartbeat — agent may be inactive" className="shrink-0 text-amber-400 text-xs">⚠</span>}
|
|
153
|
-
</div>
|
|
154
|
-
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-zinc-600">
|
|
155
|
-
{task.note.folder && (
|
|
156
|
-
<>
|
|
157
|
-
<span className="truncate max-w-[80px] text-zinc-500">{task.note.folder.name}</span>
|
|
158
|
-
<span>/</span>
|
|
159
|
-
</>
|
|
160
|
-
)}
|
|
161
|
-
<span className="truncate max-w-[100px]">{task.note.title}</span>
|
|
162
|
-
{formattedDueDate && (
|
|
163
|
-
<>
|
|
164
|
-
<span>·</span>
|
|
165
|
-
<span className={taskIsOverdue ? "text-red-300" : "text-zinc-500"}>{formattedDueDate}</span>
|
|
166
|
-
</>
|
|
167
|
-
)}
|
|
168
|
-
{assigneeLabel && (
|
|
169
|
-
<>
|
|
170
|
-
<span>·</span>
|
|
171
|
-
<span className={task.assigneeType === "AGENT" ? "text-blue-400" : "text-zinc-500"}>
|
|
172
|
-
{assigneeLabel}
|
|
173
|
-
</span>
|
|
174
|
-
</>
|
|
175
|
-
)}
|
|
176
|
-
</div>
|
|
177
|
-
</div>
|
|
178
|
-
|
|
179
|
-
{/* "..." menu button */}
|
|
180
|
-
<button
|
|
181
|
-
ref={buttonRef}
|
|
182
|
-
type="button"
|
|
183
|
-
aria-label="Move task"
|
|
184
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
185
|
-
onTouchStart={(e) => e.stopPropagation()}
|
|
186
|
-
onClick={handleMenuButton}
|
|
187
|
-
className="absolute top-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 opacity-0 group-hover:opacity-100 md:opacity-0 md:group-hover:opacity-100 max-md:opacity-70 transition-opacity"
|
|
188
|
-
>
|
|
189
|
-
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
|
|
190
|
-
<circle cx="8" cy="3" r="1.5" />
|
|
191
|
-
<circle cx="8" cy="8" r="1.5" />
|
|
192
|
-
<circle cx="8" cy="13" r="1.5" />
|
|
193
|
-
</svg>
|
|
194
|
-
</button>
|
|
195
|
-
|
|
196
|
-
{/* Desktop: floating context menu */}
|
|
197
|
-
{!isMobile && menuOpen && menuPos && createPortal(
|
|
198
|
-
<KanbanCardMenu
|
|
199
|
-
ref={menuRef}
|
|
200
|
-
task={task}
|
|
201
|
-
statuses={statuses}
|
|
202
|
-
pos={menuPos}
|
|
203
|
-
onSelect={handleSelectStatus}
|
|
204
|
-
onClose={() => { setMenuOpen(false); setMenuPos(null); }}
|
|
205
|
-
/>,
|
|
206
|
-
document.body,
|
|
207
|
-
)}
|
|
208
|
-
|
|
209
|
-
{/* Mobile: bottom sheet */}
|
|
210
|
-
{isMobile && (
|
|
211
|
-
<MobileBottomSheet
|
|
212
|
-
open={menuOpen}
|
|
213
|
-
onClose={() => { setMenuOpen(false); setMenuPos(null); }}
|
|
214
|
-
title="Move to status"
|
|
215
|
-
>
|
|
216
|
-
<KanbanCardMenu
|
|
217
|
-
task={task}
|
|
218
|
-
statuses={statuses}
|
|
219
|
-
onSelect={handleSelectStatus}
|
|
220
|
-
onClose={() => { setMenuOpen(false); setMenuPos(null); }}
|
|
221
|
-
mobile
|
|
222
|
-
/>
|
|
223
|
-
</MobileBottomSheet>
|
|
224
|
-
)}
|
|
225
|
-
</div>
|
|
226
|
-
);
|
|
227
|
-
}
|