@notis_ai/cli 0.2.4 → 0.2.5

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.
@@ -13,7 +13,7 @@ import {
13
13
  useTool,
14
14
  type MultiSelectController,
15
15
  } from '@notis/sdk';
16
- import { ArrowsDownUpIcon as ArrowUpDown, ArrowUpRightIcon as ArrowUpRight, CubeIcon as Boxes, BookOpenIcon as BookOpen, BookOpenTextIcon as BookOpenText, CalendarIcon as CalendarDays, CaretDownIcon as ChevronDown, CaretLeftIcon as ChevronLeft, CaretRightIcon as ChevronRight, EyeIcon as Eye, FileTextIcon as FileText, FolderIcon as Folder, FolderMinusIcon as FolderMinus, FolderOpenIcon as FolderOpen, FoldersIcon as Folders, SquaresFourIcon as LayoutGrid, LinkIcon as Link2, CircleNotchIcon as Loader2, ArrowsOutIcon as Maximize2, NotePencilIcon as NotebookPen, PencilIcon as Pencil, PlusIcon as Plus, RowsIcon as Rows3, MagnifyingGlassIcon as Search, SlidersHorizontalIcon as Settings2, SlidersHorizontalIcon as SlidersHorizontal, SparkleIcon as Sparkles, NoteIcon as StickyNote, TableIcon as Table2, XIcon as X, type Icon } from '@phosphor-icons/react';
16
+ import { ArrowUpRightIcon as ArrowUpRight, CubeIcon as Boxes, BookOpenIcon as BookOpen, BookOpenTextIcon as BookOpenText, CalendarIcon as CalendarDays, CaretDownIcon as ChevronDown, CaretLeftIcon as ChevronLeft, CaretRightIcon as ChevronRight, FileTextIcon as FileText, FolderIcon as Folder, FolderMinusIcon as FolderMinus, FolderOpenIcon as FolderOpen, FolderPlusIcon as FolderPlus, FoldersIcon as Folders, SquaresFourIcon as LayoutGrid, CircleNotchIcon as Loader2, MagnifyingGlassIcon as Search, NotePencilIcon as NotebookPen, PencilIcon as Pencil, PlusIcon as Plus, NoteIcon as StickyNote, TableIcon as Table2, TrashIcon as Trash, XIcon as X, type Icon } from '@phosphor-icons/react';
17
17
  import * as PhosphorIcons from '@phosphor-icons/react';
18
18
 
19
19
  import { Button } from '@/components/ui/button';
@@ -144,6 +144,8 @@ type UpsertDocumentArgs = {
144
144
  title?: string;
145
145
  properties?: Record<string, unknown>;
146
146
  contentBlocknote?: Array<Record<string, unknown>> | null;
147
+ /** 'archive' soft-deletes the document (sets archived_at); 'restore' undoes it. */
148
+ operation?: 'create' | 'update' | 'archive' | 'restore';
147
149
  };
148
150
 
149
151
  const NOTE_DATABASE_SLUG = 'notes';
@@ -151,6 +153,16 @@ const FOLDER_DATABASE_SLUG = 'note_folders';
151
153
  const DEFAULT_NOTE_TITLE = 'Untitled note';
152
154
  const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
153
155
 
156
+ // Sentinel for the built-in "Created" date, sourced from each document's
157
+ // created_at timestamp rather than a user-defined date property.
158
+ const CREATED_DATE_FIELD = '__created__';
159
+ const CREATED_DATE_LABEL = 'Created';
160
+
161
+ type CalendarDateField = {
162
+ value: string;
163
+ label: string;
164
+ };
165
+
154
166
  const TABS: TabConfig[] = [
155
167
  { key: 'gallery', label: 'Gallery', icon: LayoutGrid },
156
168
  { key: 'table', label: 'Table', icon: Table2 },
@@ -452,6 +464,7 @@ function buildUpsertNoteArgs(args: UpsertDocumentArgs): UpsertNoteArgs {
452
464
  if (typeof args.title === 'string') payload.title = args.title;
453
465
  if (args.properties) Object.assign(payload, args.properties);
454
466
  if ('contentBlocknote' in args) payload.content_blocknote = args.contentBlocknote ?? null;
467
+ if (args.operation) payload.operation = args.operation;
455
468
  return payload;
456
469
  }
457
470
 
@@ -469,6 +482,11 @@ function getDateInputValue(value: unknown): string {
469
482
  return getDateKey(value) ?? '';
470
483
  }
471
484
 
485
+ function getNoteDateValue(note: DocumentRecord, field: string): unknown {
486
+ if (field === CREATED_DATE_FIELD) return note.createdAt;
487
+ return note.properties[field];
488
+ }
489
+
472
490
  function getRelationIds(value: unknown): string[] {
473
491
  if (!Array.isArray(value)) return [];
474
492
  return value.filter((item): item is string => isPresentString(item));
@@ -784,7 +802,7 @@ function FolderIconPicker({
784
802
  useEffect(() => {
785
803
  if (!open) return;
786
804
 
787
- function handlePointerDown(event: PointerEvent) {
805
+ function handlePointerDown(event: Event) {
788
806
  const target = event.target;
789
807
  if (target instanceof Node && containerRef.current?.contains(target)) {
790
808
  return;
@@ -883,26 +901,6 @@ function FolderIconPicker({
883
901
  );
884
902
  }
885
903
 
886
- function ToolbarIconButton({
887
- icon: Icon,
888
- label,
889
- }: {
890
- icon: Icon;
891
- label: string;
892
- }) {
893
- return (
894
- <button
895
- type="button"
896
- aria-label={label}
897
- aria-disabled="true"
898
- tabIndex={-1}
899
- className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
900
- >
901
- <Icon className="h-3.5 w-3.5" />
902
- </button>
903
- );
904
- }
905
-
906
904
  function ViewPill({
907
905
  config,
908
906
  active,
@@ -948,6 +946,8 @@ function PageHeader({
948
946
  onTitleDraftChange,
949
947
  onSubmitTitleEdit,
950
948
  onCancelTitleEdit,
949
+ onCreateNote,
950
+ creatingNote = false,
951
951
  }: {
952
952
  title: string;
953
953
  icon: string | null;
@@ -966,6 +966,8 @@ function PageHeader({
966
966
  onTitleDraftChange?: (title: string) => void;
967
967
  onSubmitTitleEdit?: () => void;
968
968
  onCancelTitleEdit?: () => void;
969
+ onCreateNote?: () => void;
970
+ creatingNote?: boolean;
969
971
  }) {
970
972
  return (
971
973
  <div className="flex flex-col gap-3 px-6 pt-6">
@@ -1036,20 +1038,14 @@ function PageHeader({
1036
1038
  ))}
1037
1039
  </div>
1038
1040
  <div className="ml-auto flex items-center gap-0.5">
1039
- <ToolbarIconButton icon={SlidersHorizontal} label="Filter" />
1040
- <ToolbarIconButton icon={ArrowUpDown} label="Sort" />
1041
- <ToolbarIconButton icon={Link2} label="Copy link" />
1042
- <ToolbarIconButton icon={Search} label="Search" />
1043
- <ToolbarIconButton icon={Eye} label="Views" />
1044
- <ToolbarIconButton icon={Maximize2} label="Expand" />
1045
1041
  <button
1046
1042
  type="button"
1047
- aria-label="New"
1048
- aria-disabled="true"
1049
- tabIndex={-1}
1050
- className="ml-1 inline-flex h-7 items-center gap-1 rounded-md bg-primary px-2.5 text-[12.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
1043
+ onClick={onCreateNote}
1044
+ disabled={!onCreateNote || creatingNote}
1045
+ className="ml-1 inline-flex h-7 items-center gap-1 rounded-md bg-primary px-2.5 text-[12.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-60"
1051
1046
  >
1052
- New
1047
+ {creatingNote ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
1048
+ {creatingNote ? 'Creating…' : 'New'}
1053
1049
  </button>
1054
1050
  </div>
1055
1051
  </div>
@@ -1058,13 +1054,13 @@ function PageHeader({
1058
1054
  }
1059
1055
 
1060
1056
  export default function NotesPage() {
1061
- const { app, route, context } = useNotis();
1057
+ const { app, route, collectionItem: rawCollectionItem } = useNotis();
1062
1058
  const navigation = useNotisNavigation();
1063
1059
  const { request } = useBackend();
1064
1060
  const upsertNote = useTool<UpsertNoteArgs, UpsertNoteResult>('LOCAL_NOTIS_DATABASE_UPSERT_NOTES');
1065
1061
 
1066
1062
  const [activeTab, setActiveTab] = useState<TabKey>('gallery');
1067
- const [activeDateProperty, setActiveDateProperty] = useState('');
1063
+ const [activeDateProperty, setActiveDateProperty] = useState(CREATED_DATE_FIELD);
1068
1064
  const [visibleMonth, setVisibleMonth] = useState(() => new Date());
1069
1065
  const [creatingDocument, setCreatingDocument] = useState(false);
1070
1066
  const [savingNoteId, setSavingNoteId] = useState<string | null>(null);
@@ -1076,9 +1072,12 @@ export default function NotesPage() {
1076
1072
  const [editingFolderTitle, setEditingFolderTitle] = useState(false);
1077
1073
  const [folderTitleOverride, setFolderTitleOverride] = useState<{ id: string; title: string } | null>(null);
1078
1074
  const [folderIconOverride, setFolderIconOverride] = useState<{ id: string; icon: string | null } | null>(null);
1075
+ // Which bulk folder action is awaiting a target-folder pick, if any.
1076
+ const [pendingFolderAction, setPendingFolderAction] = useState<'move' | 'add' | null>(null);
1077
+ const [bulkRunning, setBulkRunning] = useState(false);
1079
1078
  const folderRenameSubmittingRef = useRef(false);
1080
1079
 
1081
- const collectionItem = normalizeCollectionItem(context.collectionItem ?? context.collection_item);
1080
+ const collectionItem = normalizeCollectionItem(rawCollectionItem);
1082
1081
  const noteSchema = useNoteSchema(NOTE_DATABASE_SLUG);
1083
1082
  const noteProperties = noteSchema.properties;
1084
1083
  const titleProperty = noteProperties.find((p) => p.type === 'title');
@@ -1140,19 +1139,26 @@ export default function NotesPage() {
1140
1139
  const folderOptions = buildFolderOptions(folders);
1141
1140
  const folderNameById = new Map(folderOptions.map((f) => [f.id, f.title]));
1142
1141
 
1143
- const calendarPropertyName =
1144
- dateProperties.find((p) => p.name === activeDateProperty)?.name ?? dateProperties[0]?.name ?? '';
1142
+ // The calendar always offers a built-in "Created" field (sourced from each
1143
+ // note's created_at) plus any user-defined date properties on the database.
1144
+ const calendarDateFields: CalendarDateField[] = [
1145
+ { value: CREATED_DATE_FIELD, label: CREATED_DATE_LABEL },
1146
+ ...dateProperties.map((p) => ({ value: p.name, label: p.name })),
1147
+ ];
1148
+ const activeCalendarField = calendarDateFields.some((f) => f.value === activeDateProperty)
1149
+ ? activeDateProperty
1150
+ : CREATED_DATE_FIELD;
1145
1151
 
1146
1152
  const notesByDay = new Map<string, DocumentRecord[]>();
1147
1153
  for (const note of notes) {
1148
- const key = getDateKey(note.properties[calendarPropertyName]);
1154
+ const key = getDateKey(getNoteDateValue(note, activeCalendarField));
1149
1155
  if (!key) continue;
1150
1156
  const bucket = notesByDay.get(key) ?? [];
1151
1157
  bucket.push(note);
1152
1158
  notesByDay.set(key, bucket);
1153
1159
  }
1154
1160
  const scheduledNotesCount = notes.filter((n) =>
1155
- dateProperties.some((p) => Boolean(getDateKey(n.properties[p.name]))),
1161
+ Boolean(getDateKey(getNoteDateValue(n, activeCalendarField))),
1156
1162
  ).length;
1157
1163
 
1158
1164
  const monthDays = buildCalendarDays(visibleMonth);
@@ -1170,13 +1176,10 @@ export default function NotesPage() {
1170
1176
  }, [activeFolderId]);
1171
1177
 
1172
1178
  useEffect(() => {
1173
- if (!dateProperties.length) {
1174
- if (activeDateProperty) setActiveDateProperty('');
1175
- return;
1176
- }
1177
- if (!dateProperties.some((p) => p.name === activeDateProperty)) {
1178
- setActiveDateProperty(dateProperties[0].name);
1179
- }
1179
+ const valid =
1180
+ activeDateProperty === CREATED_DATE_FIELD ||
1181
+ dateProperties.some((p) => p.name === activeDateProperty);
1182
+ if (!valid) setActiveDateProperty(CREATED_DATE_FIELD);
1180
1183
  }, [activeDateProperty, dateProperties]);
1181
1184
 
1182
1185
  async function openNote(document: DocumentRecord) {
@@ -1323,38 +1326,96 @@ export default function NotesPage() {
1323
1326
  }
1324
1327
  }
1325
1328
 
1326
- const rowRefs = useRef(new Map<string, HTMLTableRowElement>());
1329
+ // Holds both table rows and gallery cards so Shift+Arrow can scroll the head
1330
+ // into view regardless of the active view.
1331
+ const rowRefs = useRef(new Map<string, HTMLElement>());
1332
+ const selectionEnabled = activeTab !== 'calendar';
1327
1333
  const multiSelect = useMultiSelect<DocumentRecord>({
1328
1334
  items: notes,
1329
1335
  getId: (note) => note.id,
1330
- bindKeyboardShortcuts: activeTab === 'table',
1331
- enableDragSelect: activeTab === 'table',
1336
+ bindKeyboardShortcuts: selectionEnabled,
1337
+ enableDragSelect: selectionEnabled,
1332
1338
  onHeadChange: (id) => {
1333
1339
  if (!id) return;
1334
1340
  rowRefs.current.get(id)?.scrollIntoView({ block: 'nearest' });
1335
1341
  },
1336
1342
  });
1337
1343
 
1338
- async function bulkClearFolder() {
1339
- const ids = multiSelect.getSelectedItems().map((n) => n.id);
1340
- if (ids.length === 0) return;
1344
+ // Clear the selection when the visible set changes out from under it (folder
1345
+ // switch) or when entering a view that can't act on a selection (calendar),
1346
+ // so bulk actions never apply to off-screen notes.
1347
+ const clearSelection = multiSelect.clear;
1348
+ useEffect(() => {
1349
+ clearSelection();
1350
+ }, [activeFolderId, clearSelection]);
1351
+ useEffect(() => {
1352
+ if (!selectionEnabled) clearSelection();
1353
+ }, [selectionEnabled, clearSelection]);
1354
+
1355
+ // Runs `apply` for each selected note in parallel, then clears + refetches.
1356
+ // `apply` receives the full note so handlers can read its current properties.
1357
+ async function runBulk(
1358
+ apply: (note: DocumentRecord) => UpsertDocumentArgs,
1359
+ failureMessage: string,
1360
+ ) {
1361
+ const selected = multiSelect.getSelectedItems();
1362
+ if (selected.length === 0) return;
1363
+ setBulkRunning(true);
1341
1364
  setErrorMessage(null);
1342
1365
  try {
1343
- await Promise.all(
1344
- ids.map((documentId) =>
1345
- upsertNoteDocument({
1346
- documentId,
1347
- properties: { [folderPropertyName]: [] },
1348
- }),
1349
- ),
1350
- );
1366
+ await Promise.all(selected.map((note) => upsertNoteDocument(apply(note))));
1351
1367
  multiSelect.clear();
1352
1368
  notesQuery.refetch();
1353
1369
  } catch (error) {
1354
- setErrorMessage(error instanceof Error ? error.message : 'Failed to update notes');
1370
+ setErrorMessage(error instanceof Error ? error.message : failureMessage);
1371
+ } finally {
1372
+ setBulkRunning(false);
1355
1373
  }
1356
1374
  }
1357
1375
 
1376
+ function bulkClearFolder() {
1377
+ return runBulk(
1378
+ (note) => ({ documentId: note.id, properties: { [folderPropertyName]: [] } }),
1379
+ 'Failed to update notes',
1380
+ );
1381
+ }
1382
+
1383
+ function bulkDelete() {
1384
+ return runBulk(
1385
+ (note) => ({ documentId: note.id, operation: 'archive' }),
1386
+ 'Failed to delete notes',
1387
+ );
1388
+ }
1389
+
1390
+ function bulkMoveToFolder(folderId: string) {
1391
+ return runBulk(
1392
+ (note) => ({ documentId: note.id, properties: { [folderPropertyName]: [folderId] } }),
1393
+ 'Failed to move notes',
1394
+ );
1395
+ }
1396
+
1397
+ function bulkAddToFolder(folderId: string) {
1398
+ return runBulk(
1399
+ (note) => ({
1400
+ documentId: note.id,
1401
+ properties: {
1402
+ [folderPropertyName]: Array.from(
1403
+ new Set([...getRelationIds(note.properties[folderPropertyName]), folderId]),
1404
+ ),
1405
+ },
1406
+ }),
1407
+ 'Failed to add notes to folder',
1408
+ );
1409
+ }
1410
+
1411
+ async function handleFolderPick(folderId: string) {
1412
+ const action = pendingFolderAction;
1413
+ setPendingFolderAction(null);
1414
+ if (!action) return;
1415
+ if (action === 'move') await bulkMoveToFolder(folderId);
1416
+ else await bulkAddToFolder(folderId);
1417
+ }
1418
+
1358
1419
  return (
1359
1420
  <main className="flex min-h-screen flex-col bg-background">
1360
1421
  <PageHeader
@@ -1375,6 +1436,8 @@ export default function NotesPage() {
1375
1436
  onCancelTitleEdit={cancelFolderTitleEdit}
1376
1437
  activeTab={activeTab}
1377
1438
  onTabChange={setActiveTab}
1439
+ onCreateNote={() => void createDocument()}
1440
+ creatingNote={creatingDocument}
1378
1441
  />
1379
1442
 
1380
1443
  {topLevelError ? (
@@ -1394,6 +1457,8 @@ export default function NotesPage() {
1394
1457
  creatingDocument={creatingDocument}
1395
1458
  currentFolderLabel={currentFolderLabel}
1396
1459
  hasCollectionItem={Boolean(activeFolderId)}
1460
+ multiSelect={multiSelect}
1461
+ rowRefs={rowRefs.current}
1397
1462
  />
1398
1463
  ) : null}
1399
1464
 
@@ -1417,35 +1482,63 @@ export default function NotesPage() {
1417
1482
 
1418
1483
  {!isLoading && activeTab === 'calendar' ? (
1419
1484
  <CalendarBody
1420
- notes={notes}
1421
1485
  monthDays={monthDays}
1422
1486
  visibleMonth={visibleMonth}
1423
1487
  setVisibleMonth={setVisibleMonth}
1424
1488
  notesByDay={notesByDay}
1425
1489
  statusPropertyName={statusPropertyName}
1426
- calendarPropertyName={calendarPropertyName}
1427
- dateProperties={dateProperties}
1428
- activeDateProperty={activeDateProperty}
1490
+ calendarDateFields={calendarDateFields}
1491
+ activeDateProperty={activeCalendarField}
1429
1492
  setActiveDateProperty={setActiveDateProperty}
1430
1493
  scheduledNotesCount={scheduledNotesCount}
1431
1494
  onOpen={openNote}
1432
1495
  />
1433
1496
  ) : null}
1434
1497
 
1435
- {activeTab === 'table' ? (
1498
+ {selectionEnabled ? (
1436
1499
  <>
1437
1500
  <MultiSelectDragOverlay rect={multiSelect.dragRect} />
1501
+ {pendingFolderAction ? (
1502
+ <BulkFolderPicker
1503
+ mode={pendingFolderAction}
1504
+ folderOptions={folderOptions}
1505
+ busy={bulkRunning}
1506
+ onPick={(folderId) => void handleFolderPick(folderId)}
1507
+ onClose={() => setPendingFolderAction(null)}
1508
+ />
1509
+ ) : null}
1438
1510
  <MultiSelectActionBar
1439
1511
  selectedCount={multiSelect.selectedCount}
1440
1512
  itemLabel={{ singular: 'note', plural: 'notes' }}
1441
1513
  actions={[
1514
+ {
1515
+ id: 'move-folder',
1516
+ label: 'Move to folder',
1517
+ shortcut: 'M',
1518
+ icon: <FolderOpen className="h-3.5 w-3.5" />,
1519
+ onRun: () => setPendingFolderAction('move'),
1520
+ },
1521
+ {
1522
+ id: 'add-folder',
1523
+ label: 'Add to folder',
1524
+ shortcut: 'F',
1525
+ icon: <FolderPlus className="h-3.5 w-3.5" />,
1526
+ onRun: () => setPendingFolderAction('add'),
1527
+ },
1442
1528
  {
1443
1529
  id: 'clear-folder',
1444
1530
  label: 'Move out of folder',
1445
- shortcut: 'M',
1446
1531
  icon: <FolderMinus className="h-3.5 w-3.5" />,
1447
1532
  onRun: bulkClearFolder,
1448
1533
  },
1534
+ {
1535
+ id: 'delete',
1536
+ label: 'Delete',
1537
+ shortcut: '#',
1538
+ destructive: true,
1539
+ icon: <Trash className="h-3.5 w-3.5" />,
1540
+ onRun: bulkDelete,
1541
+ },
1449
1542
  ]}
1450
1543
  />
1451
1544
  </>
@@ -1466,6 +1559,8 @@ function GalleryBody({
1466
1559
  creatingDocument,
1467
1560
  currentFolderLabel,
1468
1561
  hasCollectionItem,
1562
+ multiSelect,
1563
+ rowRefs,
1469
1564
  }: {
1470
1565
  notes: DocumentRecord[];
1471
1566
  statusPropertyName: string | null;
@@ -1474,6 +1569,8 @@ function GalleryBody({
1474
1569
  creatingDocument: boolean;
1475
1570
  currentFolderLabel: string;
1476
1571
  hasCollectionItem: boolean;
1572
+ multiSelect: MultiSelectController<DocumentRecord>;
1573
+ rowRefs: Map<string, HTMLElement>;
1477
1574
  }) {
1478
1575
  if (!notes.length) {
1479
1576
  return (
@@ -1501,13 +1598,26 @@ function GalleryBody({
1501
1598
  }
1502
1599
 
1503
1600
  return (
1504
- <div className="grid grid-cols-1 gap-4 px-4 py-4 sm:grid-cols-2 sm:px-6 lg:grid-cols-3 xl:grid-cols-4">
1601
+ <div
1602
+ {...multiSelect.getContainerProps()}
1603
+ className="grid grid-cols-1 gap-4 px-4 py-4 sm:grid-cols-2 sm:px-6 lg:grid-cols-3 xl:grid-cols-4"
1604
+ >
1505
1605
  {notes.map((note) => (
1506
1606
  <NoteCard
1507
1607
  key={note.id}
1508
1608
  note={note}
1509
1609
  statusPropertyName={statusPropertyName}
1510
1610
  onOpen={() => onOpenNote(note)}
1611
+ isSelected={multiSelect.isSelected(note.id)}
1612
+ itemProps={multiSelect.getItemProps(note.id)}
1613
+ checkboxProps={multiSelect.getCheckboxProps(note.id)}
1614
+ cardRef={(node) => {
1615
+ if (node) {
1616
+ rowRefs.set(note.id, node);
1617
+ } else {
1618
+ rowRefs.delete(note.id);
1619
+ }
1620
+ }}
1511
1621
  />
1512
1622
  ))}
1513
1623
  </div>
@@ -1518,25 +1628,56 @@ function NoteCard({
1518
1628
  note,
1519
1629
  statusPropertyName,
1520
1630
  onOpen,
1631
+ isSelected,
1632
+ itemProps,
1633
+ checkboxProps,
1634
+ cardRef,
1521
1635
  }: {
1522
1636
  note: DocumentRecord;
1523
1637
  statusPropertyName: string | null;
1524
1638
  onOpen: () => void;
1639
+ isSelected: boolean;
1640
+ itemProps: { 'data-notis-row-id': string; onMouseDown: (event: React.MouseEvent) => void };
1641
+ checkboxProps: { isSelected: boolean; onClick: (event: React.MouseEvent) => void };
1642
+ cardRef: (node: HTMLDivElement | null) => void;
1525
1643
  }) {
1526
1644
  const status = statusPropertyName ? getStatusLabel(note.properties[statusPropertyName]) : null;
1527
1645
  const coverUrl = getCoverUrl(note);
1528
1646
  const title = getNoteTitle(note);
1529
1647
  const previewText = getNotePreviewText(note);
1530
1648
 
1649
+ // A plain div (no `role="button"`) so the SDK's drag-select can arm from a
1650
+ // card body; keyboard access is preserved via tabIndex + Enter.
1531
1651
  return (
1532
- <button
1533
- type="button"
1652
+ <div
1653
+ {...itemProps}
1654
+ ref={cardRef}
1655
+ tabIndex={0}
1534
1656
  onClick={onOpen}
1657
+ onKeyDown={(e) => {
1658
+ if (e.key === 'Enter') {
1659
+ e.preventDefault();
1660
+ onOpen();
1661
+ }
1662
+ }}
1535
1663
  className={cn(
1536
- 'group flex h-full w-full flex-col overflow-hidden rounded-xl border border-border bg-card text-left',
1664
+ 'group relative flex h-full w-full cursor-pointer flex-col overflow-hidden rounded-xl border bg-card text-left',
1537
1665
  'transition-colors hover:border-foreground/20 hover:shadow-sm',
1666
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
1667
+ isSelected ? 'border-primary ring-2 ring-primary' : 'border-border',
1538
1668
  )}
1539
1669
  >
1670
+ <div className="absolute left-2 top-2 z-10">
1671
+ <MultiSelectCheckbox
1672
+ {...checkboxProps}
1673
+ alwaysVisible={isSelected}
1674
+ ariaLabel={isSelected ? 'Deselect note' : 'Select note'}
1675
+ className={cn(
1676
+ 'transition-opacity',
1677
+ !isSelected && 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
1678
+ )}
1679
+ />
1680
+ </div>
1540
1681
  <div className="relative aspect-[16/10] w-full overflow-hidden">
1541
1682
  {coverUrl ? (
1542
1683
  // eslint-disable-next-line @next/next/no-img-element
@@ -1560,7 +1701,96 @@ function NoteCard({
1560
1701
  {title}
1561
1702
  </span>
1562
1703
  </div>
1563
- </button>
1704
+ </div>
1705
+ );
1706
+ }
1707
+
1708
+ /* -------------------------------------------------------------------------- */
1709
+ /* Bulk folder picker (Move to / Add to folder) */
1710
+ /* -------------------------------------------------------------------------- */
1711
+
1712
+ function BulkFolderPicker({
1713
+ mode,
1714
+ folderOptions,
1715
+ busy,
1716
+ onPick,
1717
+ onClose,
1718
+ }: {
1719
+ mode: 'move' | 'add';
1720
+ folderOptions: FolderOption[];
1721
+ busy: boolean;
1722
+ onPick: (folderId: string) => void;
1723
+ onClose: () => void;
1724
+ }) {
1725
+ const [query, setQuery] = useState('');
1726
+ const containerRef = useRef<HTMLDivElement>(null);
1727
+ const normalizedQuery = query.trim().toLowerCase();
1728
+ const filtered = normalizedQuery
1729
+ ? folderOptions.filter((f) => f.pathLabel.toLowerCase().includes(normalizedQuery))
1730
+ : folderOptions;
1731
+
1732
+ useEffect(() => {
1733
+ function handlePointerDown(event: Event) {
1734
+ const target = event.target;
1735
+ if (target instanceof Node && containerRef.current?.contains(target)) return;
1736
+ onClose();
1737
+ }
1738
+ const root = containerRef.current?.getRootNode();
1739
+ const eventTarget =
1740
+ root instanceof ShadowRoot || root instanceof Document ? root : document;
1741
+ eventTarget.addEventListener('pointerdown', handlePointerDown);
1742
+ return () => eventTarget.removeEventListener('pointerdown', handlePointerDown);
1743
+ }, [onClose]);
1744
+
1745
+ return (
1746
+ <div
1747
+ ref={containerRef}
1748
+ role="dialog"
1749
+ aria-label={mode === 'move' ? 'Move notes to folder' : 'Add notes to folder'}
1750
+ className="fixed bottom-16 left-1/2 z-[70] w-[320px] -translate-x-1/2 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl"
1751
+ >
1752
+ <div className="mb-2 flex items-center justify-between">
1753
+ <span className="text-[12px] font-semibold text-foreground">
1754
+ {mode === 'move' ? 'Move to folder' : 'Add to folder'}
1755
+ </span>
1756
+ <button
1757
+ type="button"
1758
+ aria-label="Close"
1759
+ onClick={onClose}
1760
+ className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
1761
+ >
1762
+ <X className="h-3.5 w-3.5" />
1763
+ </button>
1764
+ </div>
1765
+ <div className="mb-2 flex items-center gap-2 rounded-md border border-border bg-background px-2">
1766
+ <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1767
+ <input
1768
+ autoFocus
1769
+ value={query}
1770
+ onChange={(event) => setQuery(event.target.value)}
1771
+ placeholder="Search folders"
1772
+ className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none"
1773
+ />
1774
+ </div>
1775
+ <div className="max-h-[220px] overflow-y-auto">
1776
+ {filtered.length ? (
1777
+ filtered.map((folder) => (
1778
+ <button
1779
+ key={folder.id}
1780
+ type="button"
1781
+ disabled={busy}
1782
+ onClick={() => onPick(folder.id)}
1783
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] text-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
1784
+ >
1785
+ <Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1786
+ <span className="truncate">{folder.pathLabel}</span>
1787
+ </button>
1788
+ ))
1789
+ ) : (
1790
+ <p className="py-6 text-center text-sm text-muted-foreground">No folders found.</p>
1791
+ )}
1792
+ </div>
1793
+ </div>
1564
1794
  );
1565
1795
  }
1566
1796
 
@@ -1595,7 +1825,7 @@ function TableBody({
1595
1825
  currentFolderLabel: string;
1596
1826
  hasCollectionItem: boolean;
1597
1827
  multiSelect: MultiSelectController<DocumentRecord>;
1598
- rowRefs: Map<string, HTMLTableRowElement>;
1828
+ rowRefs: Map<string, HTMLElement>;
1599
1829
  }) {
1600
1830
  const columns = metadataProperties.slice(0, 6);
1601
1831
  const allSelected =
@@ -1610,21 +1840,6 @@ function TableBody({
1610
1840
 
1611
1841
  return (
1612
1842
  <div className="flex flex-1 flex-col gap-4 px-6 py-5">
1613
- <div className="flex flex-wrap items-center gap-2">
1614
- <FilterChip icon={Settings2} label="Status is" value="Drafting, Review" />
1615
- <FilterChip icon={CalendarDays} label="Due" value="this week" />
1616
- <FilterChip icon={Rows3} label="Sort by" value="Updated ↓" />
1617
- <button
1618
- type="button"
1619
- className="inline-flex items-center gap-1.5 rounded-full border border-dashed border-border px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground"
1620
- >
1621
- <Plus className="h-3 w-3" />
1622
- Add filter
1623
- </button>
1624
- <div className="flex-1" />
1625
- <Eyebrow>Hide · Tags, Author</Eyebrow>
1626
- </div>
1627
-
1628
1843
  {!notes.length ? (
1629
1844
  <EmptyState
1630
1845
  icon={Table2}
@@ -1697,45 +1912,10 @@ function TableBody({
1697
1912
  </div>
1698
1913
  </div>
1699
1914
  )}
1700
-
1701
- <div className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
1702
- <div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-muted">
1703
- <Sparkles className="h-3.5 w-3.5 text-foreground" />
1704
- </div>
1705
- <div className="min-w-0 flex-1">
1706
- <p className="text-[13px] font-semibold text-foreground">Need a different view?</p>
1707
- <p className="text-[12px] text-muted-foreground">
1708
- Ask Notis to add a column, group by folder, hide shipped rows, or sort differently. No settings maze.
1709
- </p>
1710
- </div>
1711
- <div className="flex flex-wrap gap-2">
1712
- <AgentHintChip>group by Folder</AgentHintChip>
1713
- <AgentHintChip>hide Updated</AgentHintChip>
1714
- <AgentHintChip>sort by Words</AgentHintChip>
1715
- </div>
1716
- </div>
1717
1915
  </div>
1718
1916
  );
1719
1917
  }
1720
1918
 
1721
- function FilterChip({ icon: Icon, label, value }: { icon: Icon; label: string; value: string }) {
1722
- return (
1723
- <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px]">
1724
- <Icon className="h-3 w-3 text-muted-foreground" />
1725
- <span className="text-muted-foreground">{label}</span>
1726
- <span className="font-semibold text-foreground">{value}</span>
1727
- </span>
1728
- );
1729
- }
1730
-
1731
- function AgentHintChip({ children }: { children: React.ReactNode }) {
1732
- return (
1733
- <span className="rounded-full bg-muted px-2.5 py-1 font-mono text-[10px] text-foreground/80">
1734
- {children}
1735
- </span>
1736
- );
1737
- }
1738
-
1739
1919
  function TableRow({
1740
1920
  note,
1741
1921
  columns,
@@ -1976,27 +2156,23 @@ function TableCell({
1976
2156
  /* -------------------------------------------------------------------------- */
1977
2157
 
1978
2158
  function CalendarBody({
1979
- notes,
1980
2159
  monthDays,
1981
2160
  visibleMonth,
1982
2161
  setVisibleMonth,
1983
2162
  notesByDay,
1984
2163
  statusPropertyName,
1985
- calendarPropertyName,
1986
- dateProperties,
2164
+ calendarDateFields,
1987
2165
  activeDateProperty,
1988
2166
  setActiveDateProperty,
1989
2167
  scheduledNotesCount,
1990
2168
  onOpen,
1991
2169
  }: {
1992
- notes: DocumentRecord[];
1993
2170
  monthDays: Date[];
1994
2171
  visibleMonth: Date;
1995
2172
  setVisibleMonth: (d: Date) => void;
1996
2173
  notesByDay: Map<string, DocumentRecord[]>;
1997
2174
  statusPropertyName: string | null;
1998
- calendarPropertyName: string;
1999
- dateProperties: DatabaseProperty[];
2175
+ calendarDateFields: CalendarDateField[];
2000
2176
  activeDateProperty: string;
2001
2177
  setActiveDateProperty: (v: string) => void;
2002
2178
  scheduledNotesCount: number;
@@ -2035,19 +2211,14 @@ function CalendarBody({
2035
2211
  <span className="text-muted-foreground">Date field</span>
2036
2212
  <select
2037
2213
  className="appearance-none bg-transparent pr-3 font-semibold text-foreground outline-none"
2038
- value={calendarPropertyName}
2214
+ value={activeDateProperty}
2039
2215
  onChange={(e) => setActiveDateProperty(e.target.value)}
2040
- disabled={!dateProperties.length}
2041
2216
  >
2042
- {dateProperties.length ? (
2043
- dateProperties.map((p) => (
2044
- <option key={p.name} value={p.name}>
2045
- {p.name}
2046
- </option>
2047
- ))
2048
- ) : (
2049
- <option value="">—</option>
2050
- )}
2217
+ {calendarDateFields.map((field) => (
2218
+ <option key={field.value} value={field.value}>
2219
+ {field.label}
2220
+ </option>
2221
+ ))}
2051
2222
  </select>
2052
2223
  <ChevronDown className="h-3 w-3 text-muted-foreground" />
2053
2224
  </div>
@@ -2056,14 +2227,7 @@ function CalendarBody({
2056
2227
  <Eyebrow>{pluralize(scheduledNotesCount, 'note')} scheduled</Eyebrow>
2057
2228
  </div>
2058
2229
 
2059
- {!dateProperties.length ? (
2060
- <EmptyState
2061
- icon={CalendarDays}
2062
- title="No date property yet"
2063
- description="Add a date field to the notes database and this board will start plotting notes automatically."
2064
- />
2065
- ) : (
2066
- <div className="overflow-hidden rounded-lg border border-border bg-card">
2230
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
2067
2231
  <div className="grid grid-cols-7 border-b border-border bg-muted/40">
2068
2232
  {WEEKDAY_LABELS.map((label, i) => (
2069
2233
  <div
@@ -2136,33 +2300,7 @@ function CalendarBody({
2136
2300
  );
2137
2301
  })}
2138
2302
  </div>
2139
- </div>
2140
- )}
2141
-
2142
- <div className="flex flex-wrap items-center gap-4 rounded-lg border border-border bg-card px-4 py-3">
2143
- <Eyebrow>Legend</Eyebrow>
2144
- <LegendItem color="bg-amber-500" label="Drafting" />
2145
- <LegendItem color="bg-blue-500" label="Review" />
2146
- <LegendItem color="bg-emerald-500" label="Shipped" />
2147
- <LegendItem color="bg-stone-400" label="Idea" />
2148
- <div className="flex-1" />
2149
- <div className="flex flex-wrap items-center gap-2">
2150
- <Sparkles className="h-3.5 w-3.5 text-foreground" />
2151
- <span className="text-[12px] text-muted-foreground">Ask Notis to</span>
2152
- <AgentHintChip>use Publish date</AgentHintChip>
2153
- <AgentHintChip>color by Folder</AgentHintChip>
2154
- <AgentHintChip>switch to week view</AgentHintChip>
2155
- </div>
2156
2303
  </div>
2157
2304
  </div>
2158
2305
  );
2159
2306
  }
2160
-
2161
- function LegendItem({ color, label }: { color: string; label: string }) {
2162
- return (
2163
- <div className="flex items-center gap-1.5">
2164
- <span className={cn('h-0.5 w-3 rounded', color)} />
2165
- <span className="text-[11px] font-medium text-foreground/80">{label}</span>
2166
- </div>
2167
- );
2168
- }