@notis_ai/cli 0.2.4 → 0.2.6

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.
Files changed (119) hide show
  1. package/README.md +47 -5
  2. package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
  3. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  4. package/dist/scaffolds/notis-database/notis.config.ts +0 -1
  5. package/dist/scaffolds/notis-database/src/mock-runtime.ts +1 -0
  6. package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
  7. package/dist/scaffolds/notis-journal/app/globals.css +37 -0
  8. package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
  9. package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
  10. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
  11. package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
  12. package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
  13. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
  14. package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
  15. package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
  16. package/dist/scaffolds/notis-journal/components.json +20 -0
  17. package/dist/scaffolds/notis-journal/index.html +12 -0
  18. package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
  19. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  20. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  21. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  22. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  23. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  24. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  25. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
  26. package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
  27. package/dist/scaffolds/notis-journal/package.json +34 -0
  28. package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
  29. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  30. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
  31. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  32. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  33. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  34. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
  35. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
  36. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
  37. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  38. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
  39. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
  40. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  41. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
  42. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  43. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
  44. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
  45. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  46. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  47. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
  48. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
  49. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
  50. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
  51. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
  52. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
  53. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
  54. package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
  55. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
  56. package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
  57. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
  58. package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
  59. package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
  60. package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
  61. package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
  62. package/dist/scaffolds/notis-notes/app/page.tsx +325 -543
  63. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  64. package/dist/scaffolds/notis-notes/notis.config.ts +0 -1
  65. package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
  66. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  67. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  68. package/dist/scaffolds/notis-random/notis.config.ts +0 -1
  69. package/dist/scaffolds/notis-random/src/mock-runtime.ts +1 -0
  70. package/dist/scaffolds.json +11 -0
  71. package/package.json +3 -3
  72. package/skills/notis-apps/SKILL.md +64 -6
  73. package/skills/notis-apps/cli.md +38 -2
  74. package/skills/notis-cli/SKILL.md +20 -2
  75. package/skills/notis-query/cli.md +1 -1
  76. package/src/command-specs/apps.js +529 -16
  77. package/src/command-specs/index.js +1 -1
  78. package/src/command-specs/meta.js +8 -2
  79. package/src/command-specs/tools.js +50 -37
  80. package/src/runtime/agent-browser.js +273 -1
  81. package/src/runtime/app-changelog.js +79 -0
  82. package/src/runtime/app-dev-server.js +113 -6
  83. package/src/runtime/app-dev-sessions.js +176 -12
  84. package/src/runtime/app-platform.js +200 -39
  85. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  86. package/src/runtime/desktop-auth.js +93 -0
  87. package/src/runtime/profiles.js +37 -15
  88. package/src/runtime/store-screenshot.js +138 -0
  89. package/src/runtime/transport.js +21 -82
  90. package/template/.harness/index.html.tmpl +128 -6
  91. package/template/CHANGELOG.md +5 -0
  92. package/template/app/page.tsx +11 -41
  93. package/template/notis.config.ts +0 -1
  94. package/template/package-lock.json +4137 -0
  95. package/template/package.json +1 -0
  96. package/template/packages/sdk/package.json +4 -0
  97. package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  98. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  99. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
  100. package/template/packages/sdk/src/config.ts +74 -0
  101. package/template/packages/sdk/src/documents.ts +229 -0
  102. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  103. package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
  104. package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
  105. package/template/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
  106. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +54 -9
  107. package/template/packages/sdk/src/index.ts +30 -1
  108. package/template/packages/sdk/src/runtime.ts +76 -17
  109. package/template/packages/sdk/src/styles.css +148 -0
  110. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  111. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  112. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  113. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  114. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  115. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  116. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  117. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  118. package/template/metadata/cover.png +0 -0
  119. package/template/packages/sdk/src/hooks/useDatabase.ts +0 -76
@@ -1,19 +1,29 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import {
5
5
  MultiSelectActionBar,
6
6
  MultiSelectCheckbox,
7
7
  MultiSelectDragOverlay,
8
8
  useMultiSelect,
9
9
  useBackend,
10
+ useDatabaseSchema,
11
+ useDocuments,
10
12
  useNotis,
11
13
  useNotisNavigation,
12
14
  useTopBarSearch,
13
- useTool,
15
+ useUpsertDocument,
16
+ asRecord,
17
+ getDocumentPreview,
18
+ getRelationIds,
19
+ isPresentString,
20
+ optionalString,
21
+ type DatabaseProperty,
22
+ type DocumentRecord,
14
23
  type MultiSelectController,
24
+ type UpsertDocumentArgs,
15
25
  } 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';
26
+ 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
27
  import * as PhosphorIcons from '@phosphor-icons/react';
18
28
 
19
29
  import { Button } from '@/components/ui/button';
@@ -34,50 +44,6 @@ type FolderOption = {
34
44
  pathLabel: string;
35
45
  };
36
46
 
37
- type DatabasePropertyType =
38
- | 'title'
39
- | 'rich_text'
40
- | 'text'
41
- | 'number'
42
- | 'checkbox'
43
- | 'date'
44
- | 'select'
45
- | 'multi_select'
46
- | 'status'
47
- | 'relation'
48
- | 'formula'
49
- | 'files';
50
-
51
- type DatabasePropertyOption = {
52
- id?: string | null;
53
- name: string;
54
- color?: string | null;
55
- order?: number;
56
- };
57
-
58
- type DatabaseProperty = {
59
- id?: string | null;
60
- name: string;
61
- type: DatabasePropertyType;
62
- description?: string | null;
63
- options?: DatabasePropertyOption[];
64
- };
65
-
66
- type DocumentRecord = {
67
- id: string;
68
- title: string;
69
- properties: Record<string, unknown>;
70
- url?: string | null;
71
- icon?: string | null;
72
- cover?: string | null;
73
- databaseSlug?: string;
74
- contentBlocknote?: Array<Record<string, unknown>> | null;
75
- contentMarkdown?: string | null;
76
- plainText?: string | null;
77
- createdAt?: string | null;
78
- lastEditedTime?: string | null;
79
- };
80
-
81
47
  type CollectionItemContext = {
82
48
  id: string;
83
49
  title: string;
@@ -85,72 +51,21 @@ type CollectionItemContext = {
85
51
  properties: Record<string, unknown>;
86
52
  };
87
53
 
88
- type QueryFilter = {
89
- filters?: Array<{
90
- property: string;
91
- operator: string;
92
- type?: string;
93
- value: unknown;
94
- }>;
95
- sorts?: Array<{
96
- property: string;
97
- direction: 'asc' | 'desc';
98
- }>;
99
- page_size?: number;
100
- };
101
-
102
- type QueryDatabaseArgs = {
103
- database_slug: string;
104
- query?: QueryFilter;
105
- offset?: number;
106
- };
107
-
108
- type QueryDatabaseResult = {
109
- documents?: unknown[];
110
- message?: string;
111
- error?: string;
112
- };
113
-
114
- type GetDatabaseArgs = {
115
- database_slug: string;
116
- };
117
-
118
- type GetDatabaseResult = {
119
- database?: {
120
- schema?: {
121
- properties?: unknown[];
122
- };
123
- };
124
- message?: string;
125
- error?: string;
126
- };
127
-
128
- type UpsertNoteArgs = {
129
- document_id?: string;
130
- title?: string;
131
- content_blocknote?: Array<Record<string, unknown>> | null;
132
- [propertyName: string]: unknown;
133
- };
134
-
135
- type UpsertNoteResult = {
136
- status?: string;
137
- document?: unknown;
138
- message?: string;
139
- error?: string;
140
- };
141
-
142
- type UpsertDocumentArgs = {
143
- documentId?: string;
144
- title?: string;
145
- properties?: Record<string, unknown>;
146
- contentBlocknote?: Array<Record<string, unknown>> | null;
147
- };
148
-
149
54
  const NOTE_DATABASE_SLUG = 'notes';
150
55
  const FOLDER_DATABASE_SLUG = 'note_folders';
151
56
  const DEFAULT_NOTE_TITLE = 'Untitled note';
152
57
  const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
153
58
 
59
+ // Sentinel for the built-in "Created" date, sourced from each document's
60
+ // created_at timestamp rather than a user-defined date property.
61
+ const CREATED_DATE_FIELD = '__created__';
62
+ const CREATED_DATE_LABEL = 'Created';
63
+
64
+ type CalendarDateField = {
65
+ value: string;
66
+ label: string;
67
+ };
68
+
154
69
  const TABS: TabConfig[] = [
155
70
  { key: 'gallery', label: 'Gallery', icon: LayoutGrid },
156
71
  { key: 'table', label: 'Table', icon: Table2 },
@@ -239,112 +154,6 @@ const ALL_PHOSPHOR_ICON_NAMES = Object.keys(PhosphorIcons)
239
154
  .map(toKebabCase)
240
155
  .sort((a, b) => a.localeCompare(b));
241
156
 
242
- function isPresentString(value: unknown): value is string {
243
- return typeof value === 'string' && value.trim().length > 0;
244
- }
245
-
246
- function asRecord(value: unknown): Record<string, unknown> | null {
247
- return value && typeof value === 'object' && !Array.isArray(value)
248
- ? (value as Record<string, unknown>)
249
- : null;
250
- }
251
-
252
- function optionalString(value: unknown): string | null {
253
- return isPresentString(value) ? value.trim() : null;
254
- }
255
-
256
- function extractRichText(value: unknown): string {
257
- if (typeof value === 'string') return value;
258
- if (!Array.isArray(value)) return '';
259
- return value
260
- .map((item) => {
261
- const record = asRecord(item);
262
- const text = asRecord(record?.text);
263
- return optionalString(text?.content) ?? optionalString(record?.plain_text) ?? '';
264
- })
265
- .join('');
266
- }
267
-
268
- function normalizePropertyValue(value: unknown): unknown {
269
- const record = asRecord(value);
270
- if (!record) return value;
271
-
272
- const type = optionalString(record.type);
273
- if (!type) return value;
274
-
275
- if (type === 'title') return extractRichText(record.title);
276
- if (type === 'rich_text') return extractRichText(record.rich_text);
277
- if (type === 'select' || type === 'status') return optionalString(asRecord(record[type])?.name) ?? record[type] ?? null;
278
- if (type === 'multi_select') {
279
- const items = Array.isArray(record.multi_select) ? record.multi_select : [];
280
- return items.map((item) => optionalString(asRecord(item)?.name) ?? item).filter(Boolean);
281
- }
282
- if (type === 'relation') {
283
- const items = Array.isArray(record.relation) ? record.relation : [];
284
- return items.map((item) => optionalString(asRecord(item)?.id) ?? item).filter(Boolean);
285
- }
286
- if (type === 'date') return optionalString(asRecord(record.date)?.start) ?? record.date ?? null;
287
- if (type in record) return record[type];
288
- return value;
289
- }
290
-
291
- function normalizeDocumentRecord(value: unknown): DocumentRecord {
292
- const record = asRecord(value) ?? {};
293
- const rawProperties = asRecord(record.properties) ?? {};
294
- const properties: Record<string, unknown> = {};
295
- for (const [key, propertyValue] of Object.entries(rawProperties)) {
296
- properties[key] = normalizePropertyValue(propertyValue);
297
- }
298
-
299
- return {
300
- id: optionalString(record.id) ?? '',
301
- title: optionalString(record.title) ?? DEFAULT_NOTE_TITLE,
302
- url: optionalString(record.url),
303
- properties,
304
- icon: optionalString(record.icon),
305
- cover: optionalString(record.cover),
306
- databaseSlug: optionalString(record.databaseSlug) ?? optionalString(record.database_slug) ?? undefined,
307
- contentBlocknote: Array.isArray(record.contentBlocknote)
308
- ? record.contentBlocknote as Array<Record<string, unknown>>
309
- : Array.isArray(record.content_blocknote)
310
- ? record.content_blocknote as Array<Record<string, unknown>>
311
- : null,
312
- contentMarkdown: optionalString(record.contentMarkdown) ?? optionalString(record.content_markdown),
313
- plainText: optionalString(record.plainText) ?? optionalString(record.plain_text),
314
- createdAt: optionalString(record.createdAt) ?? optionalString(record.created_at) ?? optionalString(record.created_time),
315
- lastEditedTime:
316
- optionalString(record.lastEditedTime)
317
- ?? optionalString(record.last_edited_time)
318
- ?? optionalString(record.updated_at),
319
- };
320
- }
321
-
322
- function normalizeDatabaseProperty(value: unknown): DatabaseProperty | null {
323
- const record = asRecord(value);
324
- const name = optionalString(record?.name);
325
- if (!record || !name) return null;
326
- const rawOptions = Array.isArray(record.options) ? record.options : [];
327
- const options = rawOptions.flatMap((option): DatabasePropertyOption[] => {
328
- const optionRecord = asRecord(option);
329
- const optionName = optionalString(optionRecord?.name);
330
- if (!optionRecord || !optionName) return [];
331
- return [{
332
- id: optionalString(optionRecord.id),
333
- name: optionName,
334
- color: optionalString(optionRecord.color),
335
- order: typeof optionRecord.order === 'number' ? optionRecord.order : undefined,
336
- }];
337
- });
338
-
339
- return {
340
- id: optionalString(record.id),
341
- name,
342
- type: (optionalString(record.type) ?? 'rich_text') as DatabasePropertyType,
343
- description: optionalString(record.description),
344
- options,
345
- };
346
- }
347
-
348
157
  function normalizeCollectionItem(value: unknown): CollectionItemContext | null {
349
158
  const record = asRecord(value);
350
159
  const id = optionalString(record?.id);
@@ -357,104 +166,6 @@ function normalizeCollectionItem(value: unknown): CollectionItemContext | null {
357
166
  };
358
167
  }
359
168
 
360
- function useNoteSchema(databaseSlug: string) {
361
- const getDatabase = useTool<GetDatabaseArgs, GetDatabaseResult>('LOCAL_NOTIS_DATABASE_GET_DATABASE');
362
- const [properties, setProperties] = useState<DatabaseProperty[]>([]);
363
- const [loading, setLoading] = useState(true);
364
- const [error, setError] = useState<Error | null>(null);
365
- const [fetchKey, setFetchKey] = useState(0);
366
-
367
- const refetch = useCallback(() => {
368
- setFetchKey((key) => key + 1);
369
- }, []);
370
-
371
- useEffect(() => {
372
- let cancelled = false;
373
- setLoading(true);
374
- setError(null);
375
-
376
- getDatabase.call({ database_slug: databaseSlug })
377
- .then((result) => {
378
- if (cancelled) return;
379
- const message = result.error ?? result.message;
380
- if (!result.database && message) {
381
- throw new Error(message);
382
- }
383
- const rawProperties = result.database?.schema?.properties ?? [];
384
- setProperties(rawProperties.map(normalizeDatabaseProperty).filter((p): p is DatabaseProperty => Boolean(p)));
385
- setLoading(false);
386
- })
387
- .catch((err) => {
388
- if (cancelled) return;
389
- setError(err instanceof Error ? err : new Error(String(err)));
390
- setLoading(false);
391
- });
392
-
393
- return () => {
394
- cancelled = true;
395
- };
396
- }, [databaseSlug, fetchKey, getDatabase.call]);
397
-
398
- return { properties, loading, error, refetch };
399
- }
400
-
401
- function useQueryDocuments(databaseSlug: string, options: { filter?: QueryFilter; pageSize?: number; offset?: number } = {}) {
402
- const queryTool = useTool<QueryDatabaseArgs, QueryDatabaseResult>('LOCAL_NOTIS_DATABASE_QUERY');
403
- const [documents, setDocuments] = useState<DocumentRecord[]>([]);
404
- const [loading, setLoading] = useState(true);
405
- const [error, setError] = useState<Error | null>(null);
406
- const [fetchKey, setFetchKey] = useState(0);
407
- const filterKey = JSON.stringify(options.filter ?? null);
408
-
409
- const refetch = useCallback(() => {
410
- setFetchKey((key) => key + 1);
411
- }, []);
412
-
413
- useEffect(() => {
414
- let cancelled = false;
415
- setLoading(true);
416
- setError(null);
417
-
418
- queryTool.call({
419
- database_slug: databaseSlug,
420
- query: {
421
- ...(options.filter ?? {}),
422
- page_size: options.pageSize,
423
- },
424
- offset: options.offset,
425
- })
426
- .then((result) => {
427
- if (cancelled) return;
428
- const message = result.error ?? result.message;
429
- if (message && !result.documents) {
430
- throw new Error(message);
431
- }
432
- setDocuments((result.documents ?? []).map(normalizeDocumentRecord).filter((doc) => doc.id));
433
- setLoading(false);
434
- })
435
- .catch((err) => {
436
- if (cancelled) return;
437
- setError(err instanceof Error ? err : new Error(String(err)));
438
- setLoading(false);
439
- });
440
-
441
- return () => {
442
- cancelled = true;
443
- };
444
- }, [databaseSlug, fetchKey, filterKey, options.offset, options.pageSize, queryTool.call]);
445
-
446
- return { documents, loading, error, refetch };
447
- }
448
-
449
- function buildUpsertNoteArgs(args: UpsertDocumentArgs): UpsertNoteArgs {
450
- const payload: UpsertNoteArgs = {};
451
- if (args.documentId) payload.document_id = args.documentId;
452
- if (typeof args.title === 'string') payload.title = args.title;
453
- if (args.properties) Object.assign(payload, args.properties);
454
- if ('contentBlocknote' in args) payload.content_blocknote = args.contentBlocknote ?? null;
455
- return payload;
456
- }
457
-
458
169
  function pluralize(count: number, singular: string, plural = `${singular}s`): string {
459
170
  return `${count} ${count === 1 ? singular : plural}`;
460
171
  }
@@ -469,9 +180,9 @@ function getDateInputValue(value: unknown): string {
469
180
  return getDateKey(value) ?? '';
470
181
  }
471
182
 
472
- function getRelationIds(value: unknown): string[] {
473
- if (!Array.isArray(value)) return [];
474
- return value.filter((item): item is string => isPresentString(item));
183
+ function getNoteDateValue(note: DocumentRecord, field: string): unknown {
184
+ if (field === CREATED_DATE_FIELD) return note.createdAt;
185
+ return note.properties[field];
475
186
  }
476
187
 
477
188
  function getFolderTitle(document: DocumentRecord): string {
@@ -484,45 +195,8 @@ function getNoteTitle(document: DocumentRecord): string {
484
195
  return isPresentString(document.title) ? document.title : DEFAULT_NOTE_TITLE;
485
196
  }
486
197
 
487
- function getPlainTextFromBlockNote(value: unknown): string {
488
- const segments: string[] = [];
489
-
490
- function visit(node: unknown): void {
491
- if (typeof node === 'string') {
492
- if (node.trim()) segments.push(node.trim());
493
- return;
494
- }
495
- if (Array.isArray(node)) {
496
- node.forEach(visit);
497
- return;
498
- }
499
- if (!node || typeof node !== 'object') return;
500
-
501
- const record = node as Record<string, unknown>;
502
- if (typeof record.text === 'string' && record.text.trim()) {
503
- segments.push(record.text.trim());
504
- }
505
- visit(record.content);
506
- visit(record.children);
507
- }
508
-
509
- visit(value);
510
- return segments.join(' ').replace(/\s+/g, ' ').trim();
511
- }
512
-
513
198
  function getNotePreviewText(document: DocumentRecord): string {
514
- if (isPresentString(document.plainText)) {
515
- return document.plainText.replace(/\s+/g, ' ').trim();
516
- }
517
- if (isPresentString(document.contentMarkdown)) {
518
- return document.contentMarkdown
519
- .replace(/[#*_`>~[\]()]/g, ' ')
520
- .replace(/\s+/g, ' ')
521
- .trim();
522
- }
523
- const fromBlocks = getPlainTextFromBlockNote(document.contentBlocknote);
524
- if (fromBlocks) return fromBlocks;
525
- return getNoteTitle(document);
199
+ return getDocumentPreview(document);
526
200
  }
527
201
 
528
202
  function buildFolderOptions(documents: DocumentRecord[]): FolderOption[] {
@@ -784,7 +458,7 @@ function FolderIconPicker({
784
458
  useEffect(() => {
785
459
  if (!open) return;
786
460
 
787
- function handlePointerDown(event: PointerEvent) {
461
+ function handlePointerDown(event: Event) {
788
462
  const target = event.target;
789
463
  if (target instanceof Node && containerRef.current?.contains(target)) {
790
464
  return;
@@ -883,26 +557,6 @@ function FolderIconPicker({
883
557
  );
884
558
  }
885
559
 
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
560
  function ViewPill({
907
561
  config,
908
562
  active,
@@ -948,6 +602,8 @@ function PageHeader({
948
602
  onTitleDraftChange,
949
603
  onSubmitTitleEdit,
950
604
  onCancelTitleEdit,
605
+ onCreateNote,
606
+ creatingNote = false,
951
607
  }: {
952
608
  title: string;
953
609
  icon: string | null;
@@ -966,6 +622,8 @@ function PageHeader({
966
622
  onTitleDraftChange?: (title: string) => void;
967
623
  onSubmitTitleEdit?: () => void;
968
624
  onCancelTitleEdit?: () => void;
625
+ onCreateNote?: () => void;
626
+ creatingNote?: boolean;
969
627
  }) {
970
628
  return (
971
629
  <div className="flex flex-col gap-3 px-6 pt-6">
@@ -1036,20 +694,14 @@ function PageHeader({
1036
694
  ))}
1037
695
  </div>
1038
696
  <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
697
  <button
1046
698
  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"
699
+ onClick={onCreateNote}
700
+ disabled={!onCreateNote || creatingNote}
701
+ 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
702
  >
1052
- New
703
+ {creatingNote ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
704
+ {creatingNote ? 'Creating…' : 'New'}
1053
705
  </button>
1054
706
  </div>
1055
707
  </div>
@@ -1058,13 +710,13 @@ function PageHeader({
1058
710
  }
1059
711
 
1060
712
  export default function NotesPage() {
1061
- const { app, route, context } = useNotis();
713
+ const { app, route, collectionItem: rawCollectionItem } = useNotis();
1062
714
  const navigation = useNotisNavigation();
1063
715
  const { request } = useBackend();
1064
- const upsertNote = useTool<UpsertNoteArgs, UpsertNoteResult>('LOCAL_NOTIS_DATABASE_UPSERT_NOTES');
716
+ const { upsert: upsertNoteDocument } = useUpsertDocument(NOTE_DATABASE_SLUG);
1065
717
 
1066
718
  const [activeTab, setActiveTab] = useState<TabKey>('gallery');
1067
- const [activeDateProperty, setActiveDateProperty] = useState('');
719
+ const [activeDateProperty, setActiveDateProperty] = useState(CREATED_DATE_FIELD);
1068
720
  const [visibleMonth, setVisibleMonth] = useState(() => new Date());
1069
721
  const [creatingDocument, setCreatingDocument] = useState(false);
1070
722
  const [savingNoteId, setSavingNoteId] = useState<string | null>(null);
@@ -1076,10 +728,13 @@ export default function NotesPage() {
1076
728
  const [editingFolderTitle, setEditingFolderTitle] = useState(false);
1077
729
  const [folderTitleOverride, setFolderTitleOverride] = useState<{ id: string; title: string } | null>(null);
1078
730
  const [folderIconOverride, setFolderIconOverride] = useState<{ id: string; icon: string | null } | null>(null);
731
+ // Which bulk folder action is awaiting a target-folder pick, if any.
732
+ const [pendingFolderAction, setPendingFolderAction] = useState<'move' | 'add' | null>(null);
733
+ const [bulkRunning, setBulkRunning] = useState(false);
1079
734
  const folderRenameSubmittingRef = useRef(false);
1080
735
 
1081
- const collectionItem = normalizeCollectionItem(context.collectionItem ?? context.collection_item);
1082
- const noteSchema = useNoteSchema(NOTE_DATABASE_SLUG);
736
+ const collectionItem = normalizeCollectionItem(rawCollectionItem);
737
+ const noteSchema = useDatabaseSchema(NOTE_DATABASE_SLUG);
1083
738
  const noteProperties = noteSchema.properties;
1084
739
  const titleProperty = noteProperties.find((p) => p.type === 'title');
1085
740
  const titlePropertyName = titleProperty?.name ?? 'Title';
@@ -1112,8 +767,8 @@ export default function NotesPage() {
1112
767
  }
1113
768
  : undefined;
1114
769
 
1115
- const notesQuery = useQueryDocuments(NOTE_DATABASE_SLUG, { filter: notesFilter, pageSize: 250 });
1116
- const foldersQuery = useQueryDocuments(FOLDER_DATABASE_SLUG, { pageSize: 500 });
770
+ const notesQuery = useDocuments(NOTE_DATABASE_SLUG, { filter: notesFilter, pageSize: 250 });
771
+ const foldersQuery = useDocuments(FOLDER_DATABASE_SLUG, { pageSize: 500 });
1117
772
 
1118
773
  const { setLoading: setSearchLoading } = useTopBarSearch({
1119
774
  value: searchQuery,
@@ -1140,19 +795,26 @@ export default function NotesPage() {
1140
795
  const folderOptions = buildFolderOptions(folders);
1141
796
  const folderNameById = new Map(folderOptions.map((f) => [f.id, f.title]));
1142
797
 
1143
- const calendarPropertyName =
1144
- dateProperties.find((p) => p.name === activeDateProperty)?.name ?? dateProperties[0]?.name ?? '';
798
+ // The calendar always offers a built-in "Created" field (sourced from each
799
+ // note's created_at) plus any user-defined date properties on the database.
800
+ const calendarDateFields: CalendarDateField[] = [
801
+ { value: CREATED_DATE_FIELD, label: CREATED_DATE_LABEL },
802
+ ...dateProperties.map((p) => ({ value: p.name, label: p.name })),
803
+ ];
804
+ const activeCalendarField = calendarDateFields.some((f) => f.value === activeDateProperty)
805
+ ? activeDateProperty
806
+ : CREATED_DATE_FIELD;
1145
807
 
1146
808
  const notesByDay = new Map<string, DocumentRecord[]>();
1147
809
  for (const note of notes) {
1148
- const key = getDateKey(note.properties[calendarPropertyName]);
810
+ const key = getDateKey(getNoteDateValue(note, activeCalendarField));
1149
811
  if (!key) continue;
1150
812
  const bucket = notesByDay.get(key) ?? [];
1151
813
  bucket.push(note);
1152
814
  notesByDay.set(key, bucket);
1153
815
  }
1154
816
  const scheduledNotesCount = notes.filter((n) =>
1155
- dateProperties.some((p) => Boolean(getDateKey(n.properties[p.name]))),
817
+ Boolean(getDateKey(getNoteDateValue(n, activeCalendarField))),
1156
818
  ).length;
1157
819
 
1158
820
  const monthDays = buildCalendarDays(visibleMonth);
@@ -1170,31 +832,16 @@ export default function NotesPage() {
1170
832
  }, [activeFolderId]);
1171
833
 
1172
834
  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
- }
835
+ const valid =
836
+ activeDateProperty === CREATED_DATE_FIELD ||
837
+ dateProperties.some((p) => p.name === activeDateProperty);
838
+ if (!valid) setActiveDateProperty(CREATED_DATE_FIELD);
1180
839
  }, [activeDateProperty, dateProperties]);
1181
840
 
1182
841
  async function openNote(document: DocumentRecord) {
1183
842
  navigation.toDocument(document.id, document.title);
1184
843
  }
1185
844
 
1186
- const upsertNoteDocument = useCallback(
1187
- async (args: UpsertDocumentArgs): Promise<DocumentRecord> => {
1188
- const result = await upsertNote.call(buildUpsertNoteArgs(args));
1189
- const message = result.error ?? result.message;
1190
- if (!result.document) {
1191
- throw new Error(message ?? 'Failed to save note');
1192
- }
1193
- return normalizeDocumentRecord(result.document);
1194
- },
1195
- [upsertNote.call],
1196
- );
1197
-
1198
845
  async function createDocument() {
1199
846
  setCreatingDocument(true);
1200
847
  setErrorMessage(null);
@@ -1323,38 +970,96 @@ export default function NotesPage() {
1323
970
  }
1324
971
  }
1325
972
 
1326
- const rowRefs = useRef(new Map<string, HTMLTableRowElement>());
973
+ // Holds both table rows and gallery cards so Shift+Arrow can scroll the head
974
+ // into view regardless of the active view.
975
+ const rowRefs = useRef(new Map<string, HTMLElement>());
976
+ const selectionEnabled = activeTab !== 'calendar';
1327
977
  const multiSelect = useMultiSelect<DocumentRecord>({
1328
978
  items: notes,
1329
979
  getId: (note) => note.id,
1330
- bindKeyboardShortcuts: activeTab === 'table',
1331
- enableDragSelect: activeTab === 'table',
980
+ bindKeyboardShortcuts: selectionEnabled,
981
+ enableDragSelect: selectionEnabled,
1332
982
  onHeadChange: (id) => {
1333
983
  if (!id) return;
1334
984
  rowRefs.current.get(id)?.scrollIntoView({ block: 'nearest' });
1335
985
  },
1336
986
  });
1337
987
 
1338
- async function bulkClearFolder() {
1339
- const ids = multiSelect.getSelectedItems().map((n) => n.id);
1340
- if (ids.length === 0) return;
988
+ // Clear the selection when the visible set changes out from under it (folder
989
+ // switch) or when entering a view that can't act on a selection (calendar),
990
+ // so bulk actions never apply to off-screen notes.
991
+ const clearSelection = multiSelect.clear;
992
+ useEffect(() => {
993
+ clearSelection();
994
+ }, [activeFolderId, clearSelection]);
995
+ useEffect(() => {
996
+ if (!selectionEnabled) clearSelection();
997
+ }, [selectionEnabled, clearSelection]);
998
+
999
+ // Runs `apply` for each selected note in parallel, then clears + refetches.
1000
+ // `apply` receives the full note so handlers can read its current properties.
1001
+ async function runBulk(
1002
+ apply: (note: DocumentRecord) => UpsertDocumentArgs,
1003
+ failureMessage: string,
1004
+ ) {
1005
+ const selected = multiSelect.getSelectedItems();
1006
+ if (selected.length === 0) return;
1007
+ setBulkRunning(true);
1341
1008
  setErrorMessage(null);
1342
1009
  try {
1343
- await Promise.all(
1344
- ids.map((documentId) =>
1345
- upsertNoteDocument({
1346
- documentId,
1347
- properties: { [folderPropertyName]: [] },
1348
- }),
1349
- ),
1350
- );
1010
+ await Promise.all(selected.map((note) => upsertNoteDocument(apply(note))));
1351
1011
  multiSelect.clear();
1352
1012
  notesQuery.refetch();
1353
1013
  } catch (error) {
1354
- setErrorMessage(error instanceof Error ? error.message : 'Failed to update notes');
1014
+ setErrorMessage(error instanceof Error ? error.message : failureMessage);
1015
+ } finally {
1016
+ setBulkRunning(false);
1355
1017
  }
1356
1018
  }
1357
1019
 
1020
+ function bulkClearFolder() {
1021
+ return runBulk(
1022
+ (note) => ({ documentId: note.id, properties: { [folderPropertyName]: [] } }),
1023
+ 'Failed to update notes',
1024
+ );
1025
+ }
1026
+
1027
+ function bulkDelete() {
1028
+ return runBulk(
1029
+ (note) => ({ documentId: note.id, operation: 'archive' }),
1030
+ 'Failed to delete notes',
1031
+ );
1032
+ }
1033
+
1034
+ function bulkMoveToFolder(folderId: string) {
1035
+ return runBulk(
1036
+ (note) => ({ documentId: note.id, properties: { [folderPropertyName]: [folderId] } }),
1037
+ 'Failed to move notes',
1038
+ );
1039
+ }
1040
+
1041
+ function bulkAddToFolder(folderId: string) {
1042
+ return runBulk(
1043
+ (note) => ({
1044
+ documentId: note.id,
1045
+ properties: {
1046
+ [folderPropertyName]: Array.from(
1047
+ new Set([...getRelationIds(note.properties[folderPropertyName]), folderId]),
1048
+ ),
1049
+ },
1050
+ }),
1051
+ 'Failed to add notes to folder',
1052
+ );
1053
+ }
1054
+
1055
+ async function handleFolderPick(folderId: string) {
1056
+ const action = pendingFolderAction;
1057
+ setPendingFolderAction(null);
1058
+ if (!action) return;
1059
+ if (action === 'move') await bulkMoveToFolder(folderId);
1060
+ else await bulkAddToFolder(folderId);
1061
+ }
1062
+
1358
1063
  return (
1359
1064
  <main className="flex min-h-screen flex-col bg-background">
1360
1065
  <PageHeader
@@ -1375,6 +1080,8 @@ export default function NotesPage() {
1375
1080
  onCancelTitleEdit={cancelFolderTitleEdit}
1376
1081
  activeTab={activeTab}
1377
1082
  onTabChange={setActiveTab}
1083
+ onCreateNote={() => void createDocument()}
1084
+ creatingNote={creatingDocument}
1378
1085
  />
1379
1086
 
1380
1087
  {topLevelError ? (
@@ -1394,6 +1101,8 @@ export default function NotesPage() {
1394
1101
  creatingDocument={creatingDocument}
1395
1102
  currentFolderLabel={currentFolderLabel}
1396
1103
  hasCollectionItem={Boolean(activeFolderId)}
1104
+ multiSelect={multiSelect}
1105
+ rowRefs={rowRefs.current}
1397
1106
  />
1398
1107
  ) : null}
1399
1108
 
@@ -1417,35 +1126,63 @@ export default function NotesPage() {
1417
1126
 
1418
1127
  {!isLoading && activeTab === 'calendar' ? (
1419
1128
  <CalendarBody
1420
- notes={notes}
1421
1129
  monthDays={monthDays}
1422
1130
  visibleMonth={visibleMonth}
1423
1131
  setVisibleMonth={setVisibleMonth}
1424
1132
  notesByDay={notesByDay}
1425
1133
  statusPropertyName={statusPropertyName}
1426
- calendarPropertyName={calendarPropertyName}
1427
- dateProperties={dateProperties}
1428
- activeDateProperty={activeDateProperty}
1134
+ calendarDateFields={calendarDateFields}
1135
+ activeDateProperty={activeCalendarField}
1429
1136
  setActiveDateProperty={setActiveDateProperty}
1430
1137
  scheduledNotesCount={scheduledNotesCount}
1431
1138
  onOpen={openNote}
1432
1139
  />
1433
1140
  ) : null}
1434
1141
 
1435
- {activeTab === 'table' ? (
1142
+ {selectionEnabled ? (
1436
1143
  <>
1437
1144
  <MultiSelectDragOverlay rect={multiSelect.dragRect} />
1145
+ {pendingFolderAction ? (
1146
+ <BulkFolderPicker
1147
+ mode={pendingFolderAction}
1148
+ folderOptions={folderOptions}
1149
+ busy={bulkRunning}
1150
+ onPick={(folderId) => void handleFolderPick(folderId)}
1151
+ onClose={() => setPendingFolderAction(null)}
1152
+ />
1153
+ ) : null}
1438
1154
  <MultiSelectActionBar
1439
1155
  selectedCount={multiSelect.selectedCount}
1440
1156
  itemLabel={{ singular: 'note', plural: 'notes' }}
1441
1157
  actions={[
1158
+ {
1159
+ id: 'move-folder',
1160
+ label: 'Move to folder',
1161
+ shortcut: 'M',
1162
+ icon: <FolderOpen className="h-3.5 w-3.5" />,
1163
+ onRun: () => setPendingFolderAction('move'),
1164
+ },
1165
+ {
1166
+ id: 'add-folder',
1167
+ label: 'Add to folder',
1168
+ shortcut: 'F',
1169
+ icon: <FolderPlus className="h-3.5 w-3.5" />,
1170
+ onRun: () => setPendingFolderAction('add'),
1171
+ },
1442
1172
  {
1443
1173
  id: 'clear-folder',
1444
1174
  label: 'Move out of folder',
1445
- shortcut: 'M',
1446
1175
  icon: <FolderMinus className="h-3.5 w-3.5" />,
1447
1176
  onRun: bulkClearFolder,
1448
1177
  },
1178
+ {
1179
+ id: 'delete',
1180
+ label: 'Delete',
1181
+ shortcut: '#',
1182
+ destructive: true,
1183
+ icon: <Trash className="h-3.5 w-3.5" />,
1184
+ onRun: bulkDelete,
1185
+ },
1449
1186
  ]}
1450
1187
  />
1451
1188
  </>
@@ -1466,6 +1203,8 @@ function GalleryBody({
1466
1203
  creatingDocument,
1467
1204
  currentFolderLabel,
1468
1205
  hasCollectionItem,
1206
+ multiSelect,
1207
+ rowRefs,
1469
1208
  }: {
1470
1209
  notes: DocumentRecord[];
1471
1210
  statusPropertyName: string | null;
@@ -1474,6 +1213,8 @@ function GalleryBody({
1474
1213
  creatingDocument: boolean;
1475
1214
  currentFolderLabel: string;
1476
1215
  hasCollectionItem: boolean;
1216
+ multiSelect: MultiSelectController<DocumentRecord>;
1217
+ rowRefs: Map<string, HTMLElement>;
1477
1218
  }) {
1478
1219
  if (!notes.length) {
1479
1220
  return (
@@ -1501,13 +1242,26 @@ function GalleryBody({
1501
1242
  }
1502
1243
 
1503
1244
  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">
1245
+ <div
1246
+ {...multiSelect.getContainerProps()}
1247
+ 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"
1248
+ >
1505
1249
  {notes.map((note) => (
1506
1250
  <NoteCard
1507
1251
  key={note.id}
1508
1252
  note={note}
1509
1253
  statusPropertyName={statusPropertyName}
1510
1254
  onOpen={() => onOpenNote(note)}
1255
+ isSelected={multiSelect.isSelected(note.id)}
1256
+ itemProps={multiSelect.getItemProps(note.id)}
1257
+ checkboxProps={multiSelect.getCheckboxProps(note.id)}
1258
+ cardRef={(node) => {
1259
+ if (node) {
1260
+ rowRefs.set(note.id, node);
1261
+ } else {
1262
+ rowRefs.delete(note.id);
1263
+ }
1264
+ }}
1511
1265
  />
1512
1266
  ))}
1513
1267
  </div>
@@ -1518,25 +1272,56 @@ function NoteCard({
1518
1272
  note,
1519
1273
  statusPropertyName,
1520
1274
  onOpen,
1275
+ isSelected,
1276
+ itemProps,
1277
+ checkboxProps,
1278
+ cardRef,
1521
1279
  }: {
1522
1280
  note: DocumentRecord;
1523
1281
  statusPropertyName: string | null;
1524
1282
  onOpen: () => void;
1283
+ isSelected: boolean;
1284
+ itemProps: { 'data-notis-row-id': string; onMouseDown: (event: React.MouseEvent) => void };
1285
+ checkboxProps: { isSelected: boolean; onClick: (event: React.MouseEvent) => void };
1286
+ cardRef: (node: HTMLDivElement | null) => void;
1525
1287
  }) {
1526
1288
  const status = statusPropertyName ? getStatusLabel(note.properties[statusPropertyName]) : null;
1527
1289
  const coverUrl = getCoverUrl(note);
1528
1290
  const title = getNoteTitle(note);
1529
1291
  const previewText = getNotePreviewText(note);
1530
1292
 
1293
+ // A plain div (no `role="button"`) so the SDK's drag-select can arm from a
1294
+ // card body; keyboard access is preserved via tabIndex + Enter.
1531
1295
  return (
1532
- <button
1533
- type="button"
1296
+ <div
1297
+ {...itemProps}
1298
+ ref={cardRef}
1299
+ tabIndex={0}
1534
1300
  onClick={onOpen}
1301
+ onKeyDown={(e) => {
1302
+ if (e.key === 'Enter') {
1303
+ e.preventDefault();
1304
+ onOpen();
1305
+ }
1306
+ }}
1535
1307
  className={cn(
1536
- 'group flex h-full w-full flex-col overflow-hidden rounded-xl border border-border bg-card text-left',
1308
+ 'group relative flex h-full w-full cursor-pointer flex-col overflow-hidden rounded-xl border bg-card text-left',
1537
1309
  'transition-colors hover:border-foreground/20 hover:shadow-sm',
1310
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
1311
+ isSelected ? 'border-primary ring-2 ring-primary' : 'border-border',
1538
1312
  )}
1539
1313
  >
1314
+ <div className="absolute left-2 top-2 z-10">
1315
+ <MultiSelectCheckbox
1316
+ {...checkboxProps}
1317
+ alwaysVisible={isSelected}
1318
+ ariaLabel={isSelected ? 'Deselect note' : 'Select note'}
1319
+ className={cn(
1320
+ 'transition-opacity',
1321
+ !isSelected && 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
1322
+ )}
1323
+ />
1324
+ </div>
1540
1325
  <div className="relative aspect-[16/10] w-full overflow-hidden">
1541
1326
  {coverUrl ? (
1542
1327
  // eslint-disable-next-line @next/next/no-img-element
@@ -1560,7 +1345,96 @@ function NoteCard({
1560
1345
  {title}
1561
1346
  </span>
1562
1347
  </div>
1563
- </button>
1348
+ </div>
1349
+ );
1350
+ }
1351
+
1352
+ /* -------------------------------------------------------------------------- */
1353
+ /* Bulk folder picker (Move to / Add to folder) */
1354
+ /* -------------------------------------------------------------------------- */
1355
+
1356
+ function BulkFolderPicker({
1357
+ mode,
1358
+ folderOptions,
1359
+ busy,
1360
+ onPick,
1361
+ onClose,
1362
+ }: {
1363
+ mode: 'move' | 'add';
1364
+ folderOptions: FolderOption[];
1365
+ busy: boolean;
1366
+ onPick: (folderId: string) => void;
1367
+ onClose: () => void;
1368
+ }) {
1369
+ const [query, setQuery] = useState('');
1370
+ const containerRef = useRef<HTMLDivElement>(null);
1371
+ const normalizedQuery = query.trim().toLowerCase();
1372
+ const filtered = normalizedQuery
1373
+ ? folderOptions.filter((f) => f.pathLabel.toLowerCase().includes(normalizedQuery))
1374
+ : folderOptions;
1375
+
1376
+ useEffect(() => {
1377
+ function handlePointerDown(event: Event) {
1378
+ const target = event.target;
1379
+ if (target instanceof Node && containerRef.current?.contains(target)) return;
1380
+ onClose();
1381
+ }
1382
+ const root = containerRef.current?.getRootNode();
1383
+ const eventTarget =
1384
+ root instanceof ShadowRoot || root instanceof Document ? root : document;
1385
+ eventTarget.addEventListener('pointerdown', handlePointerDown);
1386
+ return () => eventTarget.removeEventListener('pointerdown', handlePointerDown);
1387
+ }, [onClose]);
1388
+
1389
+ return (
1390
+ <div
1391
+ ref={containerRef}
1392
+ role="dialog"
1393
+ aria-label={mode === 'move' ? 'Move notes to folder' : 'Add notes to folder'}
1394
+ 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"
1395
+ >
1396
+ <div className="mb-2 flex items-center justify-between">
1397
+ <span className="text-[12px] font-semibold text-foreground">
1398
+ {mode === 'move' ? 'Move to folder' : 'Add to folder'}
1399
+ </span>
1400
+ <button
1401
+ type="button"
1402
+ aria-label="Close"
1403
+ onClick={onClose}
1404
+ className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
1405
+ >
1406
+ <X className="h-3.5 w-3.5" />
1407
+ </button>
1408
+ </div>
1409
+ <div className="mb-2 flex items-center gap-2 rounded-md border border-border bg-background px-2">
1410
+ <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1411
+ <input
1412
+ autoFocus
1413
+ value={query}
1414
+ onChange={(event) => setQuery(event.target.value)}
1415
+ placeholder="Search folders"
1416
+ className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none"
1417
+ />
1418
+ </div>
1419
+ <div className="max-h-[220px] overflow-y-auto">
1420
+ {filtered.length ? (
1421
+ filtered.map((folder) => (
1422
+ <button
1423
+ key={folder.id}
1424
+ type="button"
1425
+ disabled={busy}
1426
+ onClick={() => onPick(folder.id)}
1427
+ 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"
1428
+ >
1429
+ <Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1430
+ <span className="truncate">{folder.pathLabel}</span>
1431
+ </button>
1432
+ ))
1433
+ ) : (
1434
+ <p className="py-6 text-center text-sm text-muted-foreground">No folders found.</p>
1435
+ )}
1436
+ </div>
1437
+ </div>
1564
1438
  );
1565
1439
  }
1566
1440
 
@@ -1595,7 +1469,7 @@ function TableBody({
1595
1469
  currentFolderLabel: string;
1596
1470
  hasCollectionItem: boolean;
1597
1471
  multiSelect: MultiSelectController<DocumentRecord>;
1598
- rowRefs: Map<string, HTMLTableRowElement>;
1472
+ rowRefs: Map<string, HTMLElement>;
1599
1473
  }) {
1600
1474
  const columns = metadataProperties.slice(0, 6);
1601
1475
  const allSelected =
@@ -1610,21 +1484,6 @@ function TableBody({
1610
1484
 
1611
1485
  return (
1612
1486
  <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
1487
  {!notes.length ? (
1629
1488
  <EmptyState
1630
1489
  icon={Table2}
@@ -1697,45 +1556,10 @@ function TableBody({
1697
1556
  </div>
1698
1557
  </div>
1699
1558
  )}
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
1559
  </div>
1718
1560
  );
1719
1561
  }
1720
1562
 
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
1563
  function TableRow({
1740
1564
  note,
1741
1565
  columns,
@@ -1976,27 +1800,23 @@ function TableCell({
1976
1800
  /* -------------------------------------------------------------------------- */
1977
1801
 
1978
1802
  function CalendarBody({
1979
- notes,
1980
1803
  monthDays,
1981
1804
  visibleMonth,
1982
1805
  setVisibleMonth,
1983
1806
  notesByDay,
1984
1807
  statusPropertyName,
1985
- calendarPropertyName,
1986
- dateProperties,
1808
+ calendarDateFields,
1987
1809
  activeDateProperty,
1988
1810
  setActiveDateProperty,
1989
1811
  scheduledNotesCount,
1990
1812
  onOpen,
1991
1813
  }: {
1992
- notes: DocumentRecord[];
1993
1814
  monthDays: Date[];
1994
1815
  visibleMonth: Date;
1995
1816
  setVisibleMonth: (d: Date) => void;
1996
1817
  notesByDay: Map<string, DocumentRecord[]>;
1997
1818
  statusPropertyName: string | null;
1998
- calendarPropertyName: string;
1999
- dateProperties: DatabaseProperty[];
1819
+ calendarDateFields: CalendarDateField[];
2000
1820
  activeDateProperty: string;
2001
1821
  setActiveDateProperty: (v: string) => void;
2002
1822
  scheduledNotesCount: number;
@@ -2035,19 +1855,14 @@ function CalendarBody({
2035
1855
  <span className="text-muted-foreground">Date field</span>
2036
1856
  <select
2037
1857
  className="appearance-none bg-transparent pr-3 font-semibold text-foreground outline-none"
2038
- value={calendarPropertyName}
1858
+ value={activeDateProperty}
2039
1859
  onChange={(e) => setActiveDateProperty(e.target.value)}
2040
- disabled={!dateProperties.length}
2041
1860
  >
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
- )}
1861
+ {calendarDateFields.map((field) => (
1862
+ <option key={field.value} value={field.value}>
1863
+ {field.label}
1864
+ </option>
1865
+ ))}
2051
1866
  </select>
2052
1867
  <ChevronDown className="h-3 w-3 text-muted-foreground" />
2053
1868
  </div>
@@ -2056,14 +1871,7 @@ function CalendarBody({
2056
1871
  <Eyebrow>{pluralize(scheduledNotesCount, 'note')} scheduled</Eyebrow>
2057
1872
  </div>
2058
1873
 
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">
1874
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
2067
1875
  <div className="grid grid-cols-7 border-b border-border bg-muted/40">
2068
1876
  {WEEKDAY_LABELS.map((label, i) => (
2069
1877
  <div
@@ -2136,33 +1944,7 @@ function CalendarBody({
2136
1944
  );
2137
1945
  })}
2138
1946
  </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
1947
  </div>
2157
1948
  </div>
2158
1949
  );
2159
1950
  }
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
- }