@notis_ai/cli 0.2.1 → 0.2.3

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 (117) hide show
  1. package/README.md +106 -170
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/dist/scaffolds/notis-database/app/globals.css +44 -0
  4. package/dist/scaffolds/notis-database/app/layout.tsx +6 -0
  5. package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
  6. package/dist/scaffolds/notis-database/components/ui/badge.tsx +28 -0
  7. package/dist/scaffolds/notis-database/components/ui/button.tsx +53 -0
  8. package/dist/scaffolds/notis-database/components/ui/card.tsx +56 -0
  9. package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
  10. package/dist/scaffolds/notis-database/components.json +20 -0
  11. package/dist/scaffolds/notis-database/index.html +12 -0
  12. package/dist/scaffolds/notis-database/lib/types.ts +134 -0
  13. package/dist/scaffolds/notis-database/lib/utils.ts +6 -0
  14. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  15. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  16. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  17. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  18. package/dist/scaffolds/notis-database/notis.config.ts +27 -0
  19. package/dist/scaffolds/notis-database/package.json +31 -0
  20. package/dist/scaffolds/notis-database/postcss.config.mjs +8 -0
  21. package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
  22. package/dist/scaffolds/notis-database/src/mock-runtime.ts +557 -0
  23. package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
  24. package/dist/scaffolds/notis-database/tsconfig.json +23 -0
  25. package/dist/scaffolds/notis-database/vite.config.ts +22 -0
  26. package/dist/scaffolds/notis-notes/app/globals.css +3 -0
  27. package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
  28. package/dist/scaffolds/notis-notes/app/page.tsx +2168 -0
  29. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
  30. package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
  31. package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
  32. package/dist/scaffolds/notis-notes/components.json +20 -0
  33. package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
  34. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  35. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  36. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  37. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  38. package/dist/scaffolds/notis-notes/notis.config.ts +36 -0
  39. package/dist/scaffolds/notis-notes/package.json +31 -0
  40. package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
  41. package/dist/scaffolds/notis-notes/tailwind.config.ts +58 -0
  42. package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
  43. package/dist/scaffolds/notis-notes/vite.config.ts +10 -0
  44. package/dist/scaffolds/notis-random/README.md +33 -0
  45. package/dist/scaffolds/notis-random/app/globals.css +11 -0
  46. package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
  47. package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
  48. package/dist/scaffolds/notis-random/app/page.tsx +222 -0
  49. package/dist/scaffolds/notis-random/index.html +12 -0
  50. package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
  51. package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
  52. package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
  53. package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
  54. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  55. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  56. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  57. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  58. package/dist/scaffolds/notis-random/notis.config.ts +43 -0
  59. package/dist/scaffolds/notis-random/package.json +32 -0
  60. package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
  61. package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
  62. package/dist/scaffolds/notis-random/src/mock-runtime.ts +128 -0
  63. package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
  64. package/dist/scaffolds/notis-random/tsconfig.json +23 -0
  65. package/dist/scaffolds/notis-random/vite.config.ts +11 -0
  66. package/dist/scaffolds.json +36 -0
  67. package/package.json +8 -3
  68. package/skills/notis-apps/SKILL.md +162 -0
  69. package/skills/notis-apps/cli.md +208 -0
  70. package/skills/notis-cli/SKILL.md +260 -0
  71. package/skills/notis-query/cli.md +39 -0
  72. package/src/cli.js +31 -3
  73. package/src/command-specs/apps.js +248 -56
  74. package/src/command-specs/helpers.js +72 -104
  75. package/src/command-specs/index.js +0 -6
  76. package/src/command-specs/meta.js +14 -13
  77. package/src/command-specs/tools.js +196 -115
  78. package/src/runtime/app-boundary-validator.js +65 -14
  79. package/src/runtime/app-dev-server.js +32 -4
  80. package/src/runtime/app-platform.js +404 -24
  81. package/src/runtime/profiles.js +12 -6
  82. package/src/runtime/transport.js +124 -39
  83. package/template/.harness/index.html.tmpl +1 -50
  84. package/template/app/page.tsx +41 -6
  85. package/template/metadata/cover.png +0 -0
  86. package/template/metadata/screenshot-1.png +0 -0
  87. package/template/metadata/screenshot-2.png +0 -0
  88. package/template/metadata/screenshot-3.png +0 -0
  89. package/template/notis.config.ts +10 -6
  90. package/template/package.json +2 -2
  91. package/template/packages/{notis-sdk → sdk}/package.json +6 -0
  92. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectActionBar.tsx +2 -1
  93. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectCheckbox.tsx +2 -2
  94. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectDragOverlay.tsx +2 -2
  95. package/template/packages/{notis-sdk → sdk}/src/config.ts +1 -1
  96. package/template/packages/{notis-sdk → sdk}/src/hooks/useDatabase.ts +1 -13
  97. package/template/packages/{notis-sdk → sdk}/src/hooks/useMultiSelect.ts +4 -3
  98. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +4 -3
  99. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +3 -3
  100. package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
  101. package/template/packages/{notis-sdk → sdk}/src/hooks/useUpsertDocument.ts +0 -8
  102. package/template/packages/{notis-sdk → sdk}/src/index.ts +2 -15
  103. package/template/packages/{notis-sdk → sdk}/src/provider.tsx +1 -1
  104. package/template/packages/{notis-sdk → sdk}/src/runtime.ts +5 -26
  105. package/src/command-specs/auth.js +0 -178
  106. package/src/command-specs/db.js +0 -163
  107. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  108. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  109. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  110. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  111. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  112. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  113. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTopBarSearch.ts +0 -0
  114. /package/template/packages/{notis-sdk → sdk}/src/styles.css +0 -0
  115. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  116. /package/template/packages/{notis-sdk → sdk}/src/vite.ts +0 -0
  117. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -0,0 +1,2168 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import {
5
+ MultiSelectActionBar,
6
+ MultiSelectCheckbox,
7
+ MultiSelectDragOverlay,
8
+ useMultiSelect,
9
+ useBackend,
10
+ useNotis,
11
+ useNotisNavigation,
12
+ useTopBarSearch,
13
+ useTool,
14
+ type MultiSelectController,
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';
17
+ import * as PhosphorIcons from '@phosphor-icons/react';
18
+
19
+ import { Button } from '@/components/ui/button';
20
+ import { cn } from '@/lib/utils';
21
+
22
+ type TabKey = 'gallery' | 'table' | 'calendar';
23
+
24
+ type TabConfig = {
25
+ key: TabKey;
26
+ label: string;
27
+ icon: Icon;
28
+ };
29
+
30
+ type FolderOption = {
31
+ id: string;
32
+ title: string;
33
+ parentId: string | null;
34
+ pathLabel: string;
35
+ };
36
+
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
+ type CollectionItemContext = {
82
+ id: string;
83
+ title: string;
84
+ icon?: string | null;
85
+ properties: Record<string, unknown>;
86
+ };
87
+
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
+ const NOTE_DATABASE_SLUG = 'notes';
150
+ const FOLDER_DATABASE_SLUG = 'note_folders';
151
+ const DEFAULT_NOTE_TITLE = 'Untitled note';
152
+ const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
153
+
154
+ const TABS: TabConfig[] = [
155
+ { key: 'gallery', label: 'Gallery', icon: LayoutGrid },
156
+ { key: 'table', label: 'Table', icon: Table2 },
157
+ { key: 'calendar', label: 'Calendar', icon: CalendarDays },
158
+ ];
159
+
160
+ type IconName = string;
161
+
162
+ const PHOSPHOR_ICON_PREFIX = 'phosphor:';
163
+
164
+ const PHOSPHOR_ICON_OVERRIDES: Record<string, string> = {
165
+ boxes: 'Cube',
166
+ 'calendar-days': 'Calendar',
167
+ 'layout-grid': 'SquaresFour',
168
+ 'layout-dashboard': 'SquaresFour',
169
+ 'link-2': 'Link',
170
+ 'notebook-pen': 'NotePencil',
171
+ 'rows-3': 'Rows',
172
+ search: 'MagnifyingGlass',
173
+ 'settings-2': 'SlidersHorizontal',
174
+ sparkles: 'Sparkle',
175
+ 'sticky-note': 'Note',
176
+ 'table-2': 'Table',
177
+ home: 'House',
178
+ 'folder-kanban': 'Kanban',
179
+ };
180
+
181
+ const SUGGESTED_FOLDER_ICON_NAMES: IconName[] = [
182
+ 'folder',
183
+ 'folder-open',
184
+ 'folder-kanban',
185
+ 'archive',
186
+ 'bookmark',
187
+ 'book-open',
188
+ 'briefcase',
189
+ 'calendar',
190
+ 'file-text',
191
+ 'flag',
192
+ 'house',
193
+ 'lightbulb',
194
+ 'note-pencil',
195
+ 'palette',
196
+ 'rocket',
197
+ 'magnifying-glass',
198
+ 'sparkle',
199
+ 'tag',
200
+ ];
201
+
202
+ function toPascalCase(value: string): string {
203
+ return value
204
+ .split(/[-_\s]+/)
205
+ .filter(Boolean)
206
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
207
+ .join('');
208
+ }
209
+
210
+ function toKebabCase(value: string): string {
211
+ return value
212
+ .replace(/Icon$/, '')
213
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
214
+ .toLowerCase();
215
+ }
216
+
217
+ function parsePhosphorIconName(icon: string | null | undefined): IconName | null {
218
+ if (typeof icon !== 'string' || !icon.startsWith(PHOSPHOR_ICON_PREFIX)) {
219
+ return null;
220
+ }
221
+ const name = icon.slice(PHOSPHOR_ICON_PREFIX.length).trim().replace(/_/g, '-').toLowerCase();
222
+ return name && /^[a-z][a-z0-9-]*$/.test(name) ? name : null;
223
+ }
224
+
225
+ function getPhosphorIconComponent(icon: string | null | undefined): Icon | null {
226
+ const name = parsePhosphorIconName(icon);
227
+ if (!name) return null;
228
+ const exportBaseName = PHOSPHOR_ICON_OVERRIDES[name] ?? toPascalCase(name);
229
+ const component = (PhosphorIcons as Record<string, unknown>)[`${exportBaseName}Icon`];
230
+ return typeof component === 'function' ? (component as Icon) : null;
231
+ }
232
+
233
+ function getPhosphorIconComponentByName(name: string): Icon | null {
234
+ return getPhosphorIconComponent(`${PHOSPHOR_ICON_PREFIX}${name}`);
235
+ }
236
+
237
+ const ALL_PHOSPHOR_ICON_NAMES = Object.keys(PhosphorIcons)
238
+ .filter((key) => key.endsWith('Icon') && key !== 'Icon')
239
+ .map(toKebabCase)
240
+ .sort((a, b) => a.localeCompare(b));
241
+
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
+ function normalizeCollectionItem(value: unknown): CollectionItemContext | null {
349
+ const record = asRecord(value);
350
+ const id = optionalString(record?.id);
351
+ if (!record || !id) return null;
352
+ return {
353
+ id,
354
+ title: optionalString(record.title) ?? 'Untitled',
355
+ icon: optionalString(record.icon),
356
+ properties: asRecord(record.properties) ?? {},
357
+ };
358
+ }
359
+
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
+ function pluralize(count: number, singular: string, plural = `${singular}s`): string {
459
+ return `${count} ${count === 1 ? singular : plural}`;
460
+ }
461
+
462
+ function getDateKey(value: unknown): string | null {
463
+ if (!isPresentString(value)) return null;
464
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})/);
465
+ return match ? match[1] : null;
466
+ }
467
+
468
+ function getDateInputValue(value: unknown): string {
469
+ return getDateKey(value) ?? '';
470
+ }
471
+
472
+ function getRelationIds(value: unknown): string[] {
473
+ if (!Array.isArray(value)) return [];
474
+ return value.filter((item): item is string => isPresentString(item));
475
+ }
476
+
477
+ function getFolderTitle(document: DocumentRecord): string {
478
+ const explicit = document.properties.Name;
479
+ if (isPresentString(explicit)) return explicit;
480
+ return document.title || 'Untitled folder';
481
+ }
482
+
483
+ function getNoteTitle(document: DocumentRecord): string {
484
+ return isPresentString(document.title) ? document.title : DEFAULT_NOTE_TITLE;
485
+ }
486
+
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
+ 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);
526
+ }
527
+
528
+ function buildFolderOptions(documents: DocumentRecord[]): FolderOption[] {
529
+ const byId = new Map(documents.map((d) => [d.id, d]));
530
+
531
+ function buildPath(id: string, seen: Set<string> = new Set()): string {
532
+ const folder = byId.get(id);
533
+ if (!folder) return 'Untitled folder';
534
+ const title = getFolderTitle(folder);
535
+ if (seen.has(id)) return title;
536
+ seen.add(id);
537
+ const parentId = getRelationIds(folder.properties.Parent)[0] ?? null;
538
+ if (!parentId || !byId.has(parentId)) return title;
539
+ return `${buildPath(parentId, seen)} / ${title}`;
540
+ }
541
+
542
+ return documents
543
+ .map((d) => ({
544
+ id: d.id,
545
+ title: getFolderTitle(d),
546
+ parentId: getRelationIds(d.properties.Parent)[0] ?? null,
547
+ pathLabel: buildPath(d.id),
548
+ }))
549
+ .sort((a, b) => a.pathLabel.localeCompare(b.pathLabel));
550
+ }
551
+
552
+ function getCoverUrl(document: DocumentRecord): string | null {
553
+ if (isPresentString(document.cover)) {
554
+ return document.cover.trim();
555
+ }
556
+ return null;
557
+ }
558
+
559
+ function NoteIcon({
560
+ icon,
561
+ className,
562
+ fallbackClassName,
563
+ }: {
564
+ icon: string | null | undefined;
565
+ className?: string;
566
+ fallbackClassName?: string;
567
+ }) {
568
+ const Icon = getPhosphorIconComponent(icon);
569
+ if (Icon) {
570
+ return <Icon className={cn('h-3.5 w-3.5', className)} />;
571
+ }
572
+ if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
573
+ return (
574
+ <img
575
+ src={icon.trim()}
576
+ alt=""
577
+ className={cn('h-3.5 w-3.5 rounded-sm object-cover', className)}
578
+ />
579
+ );
580
+ }
581
+ return <FileText className={cn('h-3.5 w-3.5', className, fallbackClassName)} />;
582
+ }
583
+
584
+ function formatDateLabel(value: unknown): string {
585
+ const key = getDateKey(value);
586
+ if (!key) return 'No date';
587
+ const date = new Date(`${key}T12:00:00`);
588
+ return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date);
589
+ }
590
+
591
+ function formatPropertyValue(
592
+ value: unknown,
593
+ property: DatabaseProperty | null,
594
+ folderNameById: Map<string, string>,
595
+ ): string {
596
+ if (property?.type === 'relation') {
597
+ const ids = getRelationIds(value);
598
+ if (!ids.length) return 'None';
599
+ return ids.map((id) => folderNameById.get(id) ?? id).join(', ');
600
+ }
601
+ if (property?.type === 'date') return formatDateLabel(value);
602
+ if (Array.isArray(value)) return value.length ? value.map((v) => String(v)).join(', ') : 'None';
603
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
604
+ if (typeof value === 'number') return String(value);
605
+ if (isPresentString(value)) return value;
606
+ return 'None';
607
+ }
608
+
609
+ function formatMonthLabel(month: Date): string {
610
+ return new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(month);
611
+ }
612
+
613
+ function startOfCalendarGrid(month: Date): Date {
614
+ const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
615
+ const mondayIndex = (first.getDay() + 6) % 7;
616
+ const start = new Date(first);
617
+ start.setDate(first.getDate() - mondayIndex);
618
+ return start;
619
+ }
620
+
621
+ function buildCalendarDays(month: Date): Date[] {
622
+ const start = startOfCalendarGrid(month);
623
+ return Array.from({ length: 42 }, (_, i) => {
624
+ const d = new Date(start);
625
+ d.setDate(start.getDate() + i);
626
+ return d;
627
+ });
628
+ }
629
+
630
+ function isSameMonth(a: Date, b: Date): boolean {
631
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
632
+ }
633
+
634
+ function isToday(day: Date): boolean {
635
+ const t = new Date();
636
+ return day.getFullYear() === t.getFullYear() && day.getMonth() === t.getMonth() && day.getDate() === t.getDate();
637
+ }
638
+
639
+ function getStatusLabel(value: unknown): string | null {
640
+ return isPresentString(value) ? value : null;
641
+ }
642
+
643
+ type StatusTone = 'active' | 'review' | 'done' | 'blocked' | 'idea' | 'neutral';
644
+
645
+ function getStatusTone(status: string | null): StatusTone {
646
+ if (!status) return 'neutral';
647
+ const s = status.toLowerCase();
648
+ if (s.includes('done') || s.includes('complete') || s.includes('ship')) return 'done';
649
+ if (s.includes('progress') || s.includes('active') || s.includes('draft')) return 'active';
650
+ if (s.includes('review') || s.includes('wait') || s.includes('hold')) return 'review';
651
+ if (s.includes('block') || s.includes('stuck')) return 'blocked';
652
+ if (s.includes('idea') || s.includes('backlog')) return 'idea';
653
+ return 'neutral';
654
+ }
655
+
656
+ const statusPillClasses: Record<StatusTone, string> = {
657
+ active: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
658
+ review: 'bg-blue-500/10 text-blue-700 dark:text-blue-400',
659
+ done: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
660
+ blocked: 'bg-red-500/10 text-red-700 dark:text-red-400',
661
+ idea: 'bg-muted text-muted-foreground',
662
+ neutral: 'bg-muted text-muted-foreground',
663
+ };
664
+
665
+ const statusDotClasses: Record<StatusTone, string> = {
666
+ active: 'bg-amber-500',
667
+ review: 'bg-blue-500',
668
+ done: 'bg-emerald-500',
669
+ blocked: 'bg-red-500',
670
+ idea: 'bg-stone-400',
671
+ neutral: 'bg-stone-400',
672
+ };
673
+
674
+ const statusBarClasses: Record<StatusTone, string> = {
675
+ active: 'border-l-amber-500',
676
+ review: 'border-l-blue-500',
677
+ done: 'border-l-emerald-500',
678
+ blocked: 'border-l-red-500',
679
+ idea: 'border-l-stone-400',
680
+ neutral: 'border-l-stone-400',
681
+ };
682
+
683
+ function StatusPill({ status }: { status: string | null }) {
684
+ if (!status) return null;
685
+ const tone = getStatusTone(status);
686
+ return (
687
+ <span
688
+ className={cn(
689
+ 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-tight',
690
+ statusPillClasses[tone],
691
+ )}
692
+ >
693
+ <span className={cn('h-1.5 w-1.5 rounded-full', statusDotClasses[tone])} />
694
+ {status}
695
+ </span>
696
+ );
697
+ }
698
+
699
+ function Eyebrow({ children, className }: { children: React.ReactNode; className?: string }) {
700
+ return (
701
+ <span
702
+ className={cn(
703
+ 'font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground',
704
+ className,
705
+ )}
706
+ >
707
+ {children}
708
+ </span>
709
+ );
710
+ }
711
+
712
+ function EmptyState({
713
+ icon: Icon,
714
+ title,
715
+ description,
716
+ action,
717
+ }: {
718
+ icon: Icon;
719
+ title: string;
720
+ description: string;
721
+ action?: React.ReactNode;
722
+ }) {
723
+ return (
724
+ <div className="flex flex-col items-center justify-center px-6 py-16 text-center">
725
+ <Icon className="mb-4 h-10 w-10 stroke-[1.5] text-muted-foreground/30" />
726
+ <p className="text-sm font-semibold text-foreground">{title}</p>
727
+ <p className="mt-1 max-w-sm text-[13px] text-muted-foreground">{description}</p>
728
+ {action ? <div className="mt-5">{action}</div> : null}
729
+ </div>
730
+ );
731
+ }
732
+
733
+ function LoadingState() {
734
+ return (
735
+ <div className="flex min-h-[360px] items-center justify-center gap-2 text-sm text-muted-foreground">
736
+ <Loader2 className="h-4 w-4 animate-spin" />
737
+ Loading notes…
738
+ </div>
739
+ );
740
+ }
741
+
742
+ function PageIcon({ icon }: { icon: string | null | undefined }) {
743
+ const Icon = getPhosphorIconComponent(icon);
744
+ if (Icon) {
745
+ return <Icon className="h-7 w-7 stroke-[1.5] text-foreground" />;
746
+ }
747
+ if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
748
+ return (
749
+ <img
750
+ src={icon.trim()}
751
+ alt=""
752
+ className="h-7 w-7 rounded-md object-cover"
753
+ />
754
+ );
755
+ }
756
+ return <Boxes className="h-7 w-7 stroke-[1.5] text-foreground" />;
757
+ }
758
+
759
+ function FolderIconPicker({
760
+ displayIcon,
761
+ storedIcon,
762
+ disabled,
763
+ onChange,
764
+ onRemove,
765
+ }: {
766
+ displayIcon: string | null;
767
+ storedIcon: string | null;
768
+ disabled?: boolean;
769
+ onChange: (icon: string) => void;
770
+ onRemove: () => void;
771
+ }) {
772
+ const [open, setOpen] = useState(false);
773
+ const [query, setQuery] = useState('');
774
+ const containerRef = useRef<HTMLDivElement>(null);
775
+ const selectedName = parsePhosphorIconName(storedIcon);
776
+ const filteredIcons = useMemo(() => {
777
+ const normalizedQuery = query.trim().toLowerCase();
778
+ if (!normalizedQuery) return SUGGESTED_FOLDER_ICON_NAMES;
779
+ return ALL_PHOSPHOR_ICON_NAMES
780
+ .filter((name) => name.replace(/-/g, ' ').includes(normalizedQuery))
781
+ .slice(0, 96);
782
+ }, [query]);
783
+
784
+ useEffect(() => {
785
+ if (!open) return;
786
+
787
+ function handlePointerDown(event: PointerEvent) {
788
+ const target = event.target;
789
+ if (target instanceof Node && containerRef.current?.contains(target)) {
790
+ return;
791
+ }
792
+ setOpen(false);
793
+ }
794
+
795
+ const root = containerRef.current?.getRootNode();
796
+ const eventTarget =
797
+ root instanceof ShadowRoot || root instanceof Document
798
+ ? root
799
+ : document;
800
+ eventTarget.addEventListener('pointerdown', handlePointerDown);
801
+ return () => eventTarget.removeEventListener('pointerdown', handlePointerDown);
802
+ }, [open]);
803
+
804
+ return (
805
+ <div ref={containerRef} className="relative w-fit">
806
+ <button
807
+ type="button"
808
+ aria-label="Edit folder icon"
809
+ disabled={disabled}
810
+ onClick={() => setOpen((value) => !value)}
811
+ className="group/icon inline-flex size-10 items-center justify-center rounded-lg text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60"
812
+ >
813
+ <PageIcon icon={displayIcon} />
814
+ </button>
815
+ {open ? (
816
+ <div className="absolute left-0 top-12 z-20 w-[320px] rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl">
817
+ <div className="mb-2 flex items-center gap-2">
818
+ <input
819
+ autoFocus
820
+ value={query}
821
+ onChange={(event) => setQuery(event.target.value)}
822
+ placeholder="Search icons"
823
+ className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
824
+ />
825
+ <button
826
+ type="button"
827
+ aria-label="Close icon picker"
828
+ onClick={() => setOpen(false)}
829
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
830
+ >
831
+ <X className="h-3.5 w-3.5" />
832
+ </button>
833
+ </div>
834
+ <div className="grid max-h-[220px] grid-cols-8 gap-1 overflow-y-auto pr-1">
835
+ {filteredIcons.map((name) => {
836
+ const Icon = getPhosphorIconComponentByName(name);
837
+ if (!Icon) return null;
838
+
839
+ return (
840
+ <button
841
+ key={name}
842
+ type="button"
843
+ title={name}
844
+ aria-label={`Use ${name} icon`}
845
+ onPointerDown={(event) => {
846
+ event.preventDefault();
847
+ event.stopPropagation();
848
+ onChange(`${PHOSPHOR_ICON_PREFIX}${name}`);
849
+ setOpen(false);
850
+ }}
851
+ className={cn(
852
+ 'inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
853
+ selectedName === name && 'bg-muted text-foreground ring-1 ring-border',
854
+ )}
855
+ >
856
+ <Icon className="h-4 w-4" />
857
+ </button>
858
+ );
859
+ })}
860
+ </div>
861
+ {filteredIcons.length === 0 ? (
862
+ <p className="py-6 text-center text-sm text-muted-foreground">No icons found.</p>
863
+ ) : null}
864
+ <div className="mt-3 border-t border-border pt-2">
865
+ <button
866
+ type="button"
867
+ disabled={!storedIcon}
868
+ onPointerDown={(event) => {
869
+ event.preventDefault();
870
+ event.stopPropagation();
871
+ if (!storedIcon) return;
872
+ onRemove();
873
+ setOpen(false);
874
+ }}
875
+ className="inline-flex h-8 w-full items-center justify-center rounded-md text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
876
+ >
877
+ Remove custom icon
878
+ </button>
879
+ </div>
880
+ </div>
881
+ ) : null}
882
+ </div>
883
+ );
884
+ }
885
+
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
+ function ViewPill({
907
+ config,
908
+ active,
909
+ onSelect,
910
+ }: {
911
+ config: TabConfig;
912
+ active: boolean;
913
+ onSelect: () => void;
914
+ }) {
915
+ const Icon = config.icon;
916
+ return (
917
+ <button
918
+ type="button"
919
+ onClick={onSelect}
920
+ className={cn(
921
+ 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[12.5px] font-medium transition-colors',
922
+ active
923
+ ? 'bg-muted text-foreground'
924
+ : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
925
+ )}
926
+ >
927
+ <Icon className="h-3.5 w-3.5" />
928
+ {config.label}
929
+ </button>
930
+ );
931
+ }
932
+
933
+ function PageHeader({
934
+ title,
935
+ icon,
936
+ storedIcon = null,
937
+ iconEditable = false,
938
+ iconSaving = false,
939
+ titleEditable = false,
940
+ titleEditing = false,
941
+ titleDraft = '',
942
+ titleSaving = false,
943
+ activeTab,
944
+ onTabChange,
945
+ onIconChange,
946
+ onIconRemove,
947
+ onStartTitleEdit,
948
+ onTitleDraftChange,
949
+ onSubmitTitleEdit,
950
+ onCancelTitleEdit,
951
+ }: {
952
+ title: string;
953
+ icon: string | null;
954
+ storedIcon?: string | null;
955
+ iconEditable?: boolean;
956
+ iconSaving?: boolean;
957
+ titleEditable?: boolean;
958
+ titleEditing?: boolean;
959
+ titleDraft?: string;
960
+ titleSaving?: boolean;
961
+ activeTab: TabKey;
962
+ onTabChange: (tab: TabKey) => void;
963
+ onIconChange?: (icon: string) => void;
964
+ onIconRemove?: () => void;
965
+ onStartTitleEdit?: () => void;
966
+ onTitleDraftChange?: (title: string) => void;
967
+ onSubmitTitleEdit?: () => void;
968
+ onCancelTitleEdit?: () => void;
969
+ }) {
970
+ return (
971
+ <div className="flex flex-col gap-3 px-6 pt-6">
972
+ {iconEditable ? (
973
+ <FolderIconPicker
974
+ displayIcon={icon}
975
+ storedIcon={storedIcon}
976
+ disabled={iconSaving}
977
+ onChange={(nextIcon) => onIconChange?.(nextIcon)}
978
+ onRemove={() => onIconRemove?.()}
979
+ />
980
+ ) : (
981
+ <div className="flex size-10 items-center justify-center">
982
+ <PageIcon icon={icon} />
983
+ </div>
984
+ )}
985
+ {titleEditing ? (
986
+ <input
987
+ autoFocus
988
+ value={titleDraft}
989
+ onChange={(event) => onTitleDraftChange?.(event.target.value)}
990
+ onKeyDown={(event) => {
991
+ if (event.key === 'Enter') {
992
+ event.preventDefault();
993
+ onSubmitTitleEdit?.();
994
+ }
995
+ if (event.key === 'Escape') {
996
+ event.preventDefault();
997
+ onCancelTitleEdit?.();
998
+ }
999
+ }}
1000
+ onBlur={() => onSubmitTitleEdit?.()}
1001
+ className="h-10 w-full max-w-xl rounded-md border border-border bg-background px-2 text-3xl font-bold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
1002
+ />
1003
+ ) : (
1004
+ <div className="flex min-w-0 items-center gap-2">
1005
+ <h1
1006
+ className={cn(
1007
+ 'min-w-0 truncate text-3xl font-bold tracking-tight text-foreground',
1008
+ titleEditable && 'cursor-text',
1009
+ )}
1010
+ onDoubleClick={titleEditable ? onStartTitleEdit : undefined}
1011
+ >
1012
+ {title}
1013
+ </h1>
1014
+ {titleEditable ? (
1015
+ <button
1016
+ type="button"
1017
+ aria-label="Rename folder"
1018
+ onClick={onStartTitleEdit}
1019
+ disabled={titleSaving}
1020
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
1021
+ >
1022
+ {titleSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
1023
+ </button>
1024
+ ) : null}
1025
+ </div>
1026
+ )}
1027
+ <div className="flex flex-wrap items-center gap-2 border-b border-border pb-2">
1028
+ <div className="flex items-center gap-1">
1029
+ {TABS.map((tab) => (
1030
+ <ViewPill
1031
+ key={tab.key}
1032
+ config={tab}
1033
+ active={activeTab === tab.key}
1034
+ onSelect={() => onTabChange(tab.key)}
1035
+ />
1036
+ ))}
1037
+ </div>
1038
+ <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
+ <button
1046
+ 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"
1051
+ >
1052
+ New
1053
+ </button>
1054
+ </div>
1055
+ </div>
1056
+ </div>
1057
+ );
1058
+ }
1059
+
1060
+ export default function NotesPage() {
1061
+ const { app, route, context } = useNotis();
1062
+ const navigation = useNotisNavigation();
1063
+ const { request } = useBackend();
1064
+ const upsertNote = useTool<UpsertNoteArgs, UpsertNoteResult>('LOCAL_NOTIS_DATABASE_UPSERT_NOTES');
1065
+
1066
+ const [activeTab, setActiveTab] = useState<TabKey>('gallery');
1067
+ const [activeDateProperty, setActiveDateProperty] = useState('');
1068
+ const [visibleMonth, setVisibleMonth] = useState(() => new Date());
1069
+ const [creatingDocument, setCreatingDocument] = useState(false);
1070
+ const [savingNoteId, setSavingNoteId] = useState<string | null>(null);
1071
+ const [savingFolderTitle, setSavingFolderTitle] = useState(false);
1072
+ const [savingFolderIcon, setSavingFolderIcon] = useState(false);
1073
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
1074
+ const [searchQuery, setSearchQuery] = useState('');
1075
+ const [folderTitleDraft, setFolderTitleDraft] = useState('');
1076
+ const [editingFolderTitle, setEditingFolderTitle] = useState(false);
1077
+ const [folderTitleOverride, setFolderTitleOverride] = useState<{ id: string; title: string } | null>(null);
1078
+ const [folderIconOverride, setFolderIconOverride] = useState<{ id: string; icon: string | null } | null>(null);
1079
+ const folderRenameSubmittingRef = useRef(false);
1080
+
1081
+ const collectionItem = normalizeCollectionItem(context.collectionItem ?? context.collection_item);
1082
+ const noteSchema = useNoteSchema(NOTE_DATABASE_SLUG);
1083
+ const noteProperties = noteSchema.properties;
1084
+ const titleProperty = noteProperties.find((p) => p.type === 'title');
1085
+ const titlePropertyName = titleProperty?.name ?? 'Title';
1086
+ const folderPropertyName = noteProperties.find((p) => p.type === 'relation')?.name ?? 'Folder';
1087
+ const statusPropertyName =
1088
+ noteProperties.find((p) => p.type === 'status' || p.name === 'Status')?.name ?? null;
1089
+ const dateProperties = noteProperties.filter((p) => p.type === 'date');
1090
+ const metadataProperties = noteProperties.filter((p) => p.name !== titlePropertyName);
1091
+
1092
+ const activeFolderId = collectionItem?.id ?? null;
1093
+ const activeFolderTitle =
1094
+ activeFolderId && folderTitleOverride?.id === activeFolderId
1095
+ ? folderTitleOverride.title
1096
+ : collectionItem?.title ?? null;
1097
+ const activeFolderIcon =
1098
+ activeFolderId && folderIconOverride?.id === activeFolderId
1099
+ ? folderIconOverride.icon
1100
+ : collectionItem?.icon ?? null;
1101
+
1102
+ const notesFilter = activeFolderId
1103
+ ? {
1104
+ filters: [
1105
+ {
1106
+ property: folderPropertyName,
1107
+ operator: 'contains',
1108
+ type: 'relation',
1109
+ value: activeFolderId,
1110
+ },
1111
+ ],
1112
+ }
1113
+ : undefined;
1114
+
1115
+ const notesQuery = useQueryDocuments(NOTE_DATABASE_SLUG, { filter: notesFilter, pageSize: 250 });
1116
+ const foldersQuery = useQueryDocuments(FOLDER_DATABASE_SLUG, { pageSize: 500 });
1117
+
1118
+ const { setLoading: setSearchLoading } = useTopBarSearch({
1119
+ value: searchQuery,
1120
+ onChange: setSearchQuery,
1121
+ placeholder: 'Search notes…',
1122
+ onSubmit: notesQuery.refetch,
1123
+ });
1124
+
1125
+ useEffect(() => {
1126
+ setSearchLoading(notesQuery.loading);
1127
+ }, [notesQuery.loading, setSearchLoading]);
1128
+
1129
+ const trimmedQuery = searchQuery.trim().toLowerCase();
1130
+ const allNotes = notesQuery.documents;
1131
+ const notes = trimmedQuery
1132
+ ? allNotes.filter((note) => {
1133
+ if (getNoteTitle(note).toLowerCase().includes(trimmedQuery)) return true;
1134
+ if (isPresentString(note.plainText) && note.plainText.toLowerCase().includes(trimmedQuery)) return true;
1135
+ if (isPresentString(note.contentMarkdown) && note.contentMarkdown.toLowerCase().includes(trimmedQuery)) return true;
1136
+ return false;
1137
+ })
1138
+ : allNotes;
1139
+ const folders = foldersQuery.documents;
1140
+ const folderOptions = buildFolderOptions(folders);
1141
+ const folderNameById = new Map(folderOptions.map((f) => [f.id, f.title]));
1142
+
1143
+ const calendarPropertyName =
1144
+ dateProperties.find((p) => p.name === activeDateProperty)?.name ?? dateProperties[0]?.name ?? '';
1145
+
1146
+ const notesByDay = new Map<string, DocumentRecord[]>();
1147
+ for (const note of notes) {
1148
+ const key = getDateKey(note.properties[calendarPropertyName]);
1149
+ if (!key) continue;
1150
+ const bucket = notesByDay.get(key) ?? [];
1151
+ bucket.push(note);
1152
+ notesByDay.set(key, bucket);
1153
+ }
1154
+ const scheduledNotesCount = notes.filter((n) =>
1155
+ dateProperties.some((p) => Boolean(getDateKey(n.properties[p.name]))),
1156
+ ).length;
1157
+
1158
+ const monthDays = buildCalendarDays(visibleMonth);
1159
+ const isLoading = notesQuery.loading || foldersQuery.loading || noteSchema.loading;
1160
+ const currentFolderLabel = activeFolderTitle ?? 'All notes';
1161
+ const topLevelError = errorMessage || notesQuery.error?.message || foldersQuery.error?.message || noteSchema.error?.message;
1162
+ const pageTitle = activeFolderTitle || route?.name || app?.name || 'Notes';
1163
+ const pageIcon = activeFolderIcon || route?.icon || null;
1164
+
1165
+ useEffect(() => {
1166
+ setEditingFolderTitle(false);
1167
+ setFolderTitleDraft('');
1168
+ folderRenameSubmittingRef.current = false;
1169
+ setFolderIconOverride(null);
1170
+ }, [activeFolderId]);
1171
+
1172
+ 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
+ }
1180
+ }, [activeDateProperty, dateProperties]);
1181
+
1182
+ async function openNote(document: DocumentRecord) {
1183
+ navigation.toDocument(document.id, document.title);
1184
+ }
1185
+
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
+ async function createDocument() {
1199
+ setCreatingDocument(true);
1200
+ setErrorMessage(null);
1201
+ try {
1202
+ const document = await upsertNoteDocument({
1203
+ title: DEFAULT_NOTE_TITLE,
1204
+ properties: activeFolderId ? { [folderPropertyName]: [activeFolderId] } : undefined,
1205
+ });
1206
+ navigation.toDocument(document.id, document.title);
1207
+ } catch (error) {
1208
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to create note');
1209
+ } finally {
1210
+ setCreatingDocument(false);
1211
+ }
1212
+ }
1213
+
1214
+ async function saveProperties(documentId: string, properties: Record<string, unknown>) {
1215
+ setSavingNoteId(documentId);
1216
+ setErrorMessage(null);
1217
+ try {
1218
+ await upsertNoteDocument({ documentId, properties });
1219
+ notesQuery.refetch();
1220
+ } catch (error) {
1221
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to save note');
1222
+ } finally {
1223
+ setSavingNoteId(null);
1224
+ }
1225
+ }
1226
+
1227
+ async function saveTitle(documentId: string, nextTitle: string) {
1228
+ const normalized = nextTitle.trim() || DEFAULT_NOTE_TITLE;
1229
+ setSavingNoteId(documentId);
1230
+ setErrorMessage(null);
1231
+ try {
1232
+ await upsertNoteDocument({ documentId, title: normalized });
1233
+ notesQuery.refetch();
1234
+ } catch (error) {
1235
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to rename note');
1236
+ } finally {
1237
+ setSavingNoteId(null);
1238
+ }
1239
+ }
1240
+
1241
+ function startFolderTitleEdit() {
1242
+ if (!activeFolderId) return;
1243
+ setFolderTitleDraft(activeFolderTitle ?? '');
1244
+ setEditingFolderTitle(true);
1245
+ }
1246
+
1247
+ function cancelFolderTitleEdit() {
1248
+ setEditingFolderTitle(false);
1249
+ setFolderTitleDraft('');
1250
+ folderRenameSubmittingRef.current = false;
1251
+ }
1252
+
1253
+ async function submitFolderTitleEdit() {
1254
+ if (folderRenameSubmittingRef.current) return;
1255
+ if (!activeFolderId || !app?.id || !route?.slug) {
1256
+ cancelFolderTitleEdit();
1257
+ return;
1258
+ }
1259
+
1260
+ const previousTitle = activeFolderTitle ?? collectionItem?.title ?? '';
1261
+ const nextTitle = folderTitleDraft.trim();
1262
+ if (!nextTitle || nextTitle === previousTitle) {
1263
+ cancelFolderTitleEdit();
1264
+ return;
1265
+ }
1266
+
1267
+ folderRenameSubmittingRef.current = true;
1268
+ setEditingFolderTitle(false);
1269
+ setFolderTitleOverride({ id: activeFolderId, title: nextTitle });
1270
+ setSavingFolderTitle(true);
1271
+ setErrorMessage(null);
1272
+ try {
1273
+ const response = await request('/portal_views/collection_tree/rename', {
1274
+ method: 'POST',
1275
+ body: {
1276
+ app_id: app.id,
1277
+ route_slug: route.slug,
1278
+ item_id: activeFolderId,
1279
+ title: nextTitle,
1280
+ },
1281
+ }) as { item?: { title?: unknown } };
1282
+ const savedTitle = typeof response.item?.title === 'string' && response.item.title.trim()
1283
+ ? response.item.title
1284
+ : nextTitle;
1285
+ setFolderTitleOverride({ id: activeFolderId, title: savedTitle });
1286
+ foldersQuery.refetch();
1287
+ } catch (error) {
1288
+ setFolderTitleOverride(previousTitle ? { id: activeFolderId, title: previousTitle } : null);
1289
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to rename folder');
1290
+ } finally {
1291
+ setSavingFolderTitle(false);
1292
+ setFolderTitleDraft('');
1293
+ folderRenameSubmittingRef.current = false;
1294
+ }
1295
+ }
1296
+
1297
+ async function saveFolderIcon(nextIcon: string | null) {
1298
+ if (!activeFolderId || !app?.id || !route?.slug) return;
1299
+ if (nextIcon === activeFolderIcon) return;
1300
+
1301
+ const previousIcon = activeFolderIcon;
1302
+ setFolderIconOverride({ id: activeFolderId, icon: nextIcon });
1303
+ setSavingFolderIcon(true);
1304
+ setErrorMessage(null);
1305
+ try {
1306
+ const response = await request('/portal_views/collection_tree/icon', {
1307
+ method: 'POST',
1308
+ body: {
1309
+ app_id: app.id,
1310
+ route_slug: route.slug,
1311
+ item_id: activeFolderId,
1312
+ icon: nextIcon,
1313
+ },
1314
+ }) as { item?: { icon?: unknown } };
1315
+ const savedIcon = typeof response.item?.icon === 'string' ? response.item.icon : null;
1316
+ setFolderIconOverride({ id: activeFolderId, icon: savedIcon });
1317
+ foldersQuery.refetch();
1318
+ } catch (error) {
1319
+ setFolderIconOverride({ id: activeFolderId, icon: previousIcon });
1320
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to update folder icon');
1321
+ } finally {
1322
+ setSavingFolderIcon(false);
1323
+ }
1324
+ }
1325
+
1326
+ const rowRefs = useRef(new Map<string, HTMLTableRowElement>());
1327
+ const multiSelect = useMultiSelect<DocumentRecord>({
1328
+ items: notes,
1329
+ getId: (note) => note.id,
1330
+ bindKeyboardShortcuts: activeTab === 'table',
1331
+ enableDragSelect: activeTab === 'table',
1332
+ onHeadChange: (id) => {
1333
+ if (!id) return;
1334
+ rowRefs.current.get(id)?.scrollIntoView({ block: 'nearest' });
1335
+ },
1336
+ });
1337
+
1338
+ async function bulkClearFolder() {
1339
+ const ids = multiSelect.getSelectedItems().map((n) => n.id);
1340
+ if (ids.length === 0) return;
1341
+ setErrorMessage(null);
1342
+ try {
1343
+ await Promise.all(
1344
+ ids.map((documentId) =>
1345
+ upsertNoteDocument({
1346
+ documentId,
1347
+ properties: { [folderPropertyName]: [] },
1348
+ }),
1349
+ ),
1350
+ );
1351
+ multiSelect.clear();
1352
+ notesQuery.refetch();
1353
+ } catch (error) {
1354
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to update notes');
1355
+ }
1356
+ }
1357
+
1358
+ return (
1359
+ <main className="flex min-h-screen flex-col bg-background">
1360
+ <PageHeader
1361
+ title={pageTitle}
1362
+ icon={pageIcon}
1363
+ storedIcon={activeFolderIcon}
1364
+ iconEditable={Boolean(activeFolderId)}
1365
+ iconSaving={savingFolderIcon}
1366
+ onIconChange={(nextIcon) => void saveFolderIcon(nextIcon)}
1367
+ onIconRemove={() => void saveFolderIcon(null)}
1368
+ titleEditable={Boolean(activeFolderId)}
1369
+ titleEditing={editingFolderTitle}
1370
+ titleDraft={folderTitleDraft}
1371
+ titleSaving={savingFolderTitle}
1372
+ onStartTitleEdit={startFolderTitleEdit}
1373
+ onTitleDraftChange={setFolderTitleDraft}
1374
+ onSubmitTitleEdit={() => void submitFolderTitleEdit()}
1375
+ onCancelTitleEdit={cancelFolderTitleEdit}
1376
+ activeTab={activeTab}
1377
+ onTabChange={setActiveTab}
1378
+ />
1379
+
1380
+ {topLevelError ? (
1381
+ <div className="mx-6 mt-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
1382
+ {topLevelError}
1383
+ </div>
1384
+ ) : null}
1385
+
1386
+ {isLoading ? <LoadingState /> : null}
1387
+
1388
+ {!isLoading && activeTab === 'gallery' ? (
1389
+ <GalleryBody
1390
+ notes={notes}
1391
+ statusPropertyName={statusPropertyName}
1392
+ onOpenNote={openNote}
1393
+ onCreateDocument={createDocument}
1394
+ creatingDocument={creatingDocument}
1395
+ currentFolderLabel={currentFolderLabel}
1396
+ hasCollectionItem={Boolean(activeFolderId)}
1397
+ />
1398
+ ) : null}
1399
+
1400
+ {!isLoading && activeTab === 'table' ? (
1401
+ <TableBody
1402
+ notes={notes}
1403
+ metadataProperties={metadataProperties}
1404
+ folderOptions={folderOptions}
1405
+ folderNameById={folderNameById}
1406
+ folderPropertyName={folderPropertyName}
1407
+ savingNoteId={savingNoteId}
1408
+ onOpen={openNote}
1409
+ onSaveTitle={saveTitle}
1410
+ onSaveProperties={saveProperties}
1411
+ currentFolderLabel={currentFolderLabel}
1412
+ hasCollectionItem={Boolean(activeFolderId)}
1413
+ multiSelect={multiSelect}
1414
+ rowRefs={rowRefs.current}
1415
+ />
1416
+ ) : null}
1417
+
1418
+ {!isLoading && activeTab === 'calendar' ? (
1419
+ <CalendarBody
1420
+ notes={notes}
1421
+ monthDays={monthDays}
1422
+ visibleMonth={visibleMonth}
1423
+ setVisibleMonth={setVisibleMonth}
1424
+ notesByDay={notesByDay}
1425
+ statusPropertyName={statusPropertyName}
1426
+ calendarPropertyName={calendarPropertyName}
1427
+ dateProperties={dateProperties}
1428
+ activeDateProperty={activeDateProperty}
1429
+ setActiveDateProperty={setActiveDateProperty}
1430
+ scheduledNotesCount={scheduledNotesCount}
1431
+ onOpen={openNote}
1432
+ />
1433
+ ) : null}
1434
+
1435
+ {activeTab === 'table' ? (
1436
+ <>
1437
+ <MultiSelectDragOverlay rect={multiSelect.dragRect} />
1438
+ <MultiSelectActionBar
1439
+ selectedCount={multiSelect.selectedCount}
1440
+ itemLabel={{ singular: 'note', plural: 'notes' }}
1441
+ actions={[
1442
+ {
1443
+ id: 'clear-folder',
1444
+ label: 'Move out of folder',
1445
+ shortcut: 'M',
1446
+ icon: <FolderMinus className="h-3.5 w-3.5" />,
1447
+ onRun: bulkClearFolder,
1448
+ },
1449
+ ]}
1450
+ />
1451
+ </>
1452
+ ) : null}
1453
+ </main>
1454
+ );
1455
+ }
1456
+
1457
+ /* -------------------------------------------------------------------------- */
1458
+ /* Gallery view */
1459
+ /* -------------------------------------------------------------------------- */
1460
+
1461
+ function GalleryBody({
1462
+ notes,
1463
+ statusPropertyName,
1464
+ onOpenNote,
1465
+ onCreateDocument,
1466
+ creatingDocument,
1467
+ currentFolderLabel,
1468
+ hasCollectionItem,
1469
+ }: {
1470
+ notes: DocumentRecord[];
1471
+ statusPropertyName: string | null;
1472
+ onOpenNote: (doc: DocumentRecord) => void;
1473
+ onCreateDocument: () => Promise<void>;
1474
+ creatingDocument: boolean;
1475
+ currentFolderLabel: string;
1476
+ hasCollectionItem: boolean;
1477
+ }) {
1478
+ if (!notes.length) {
1479
+ return (
1480
+ <EmptyState
1481
+ icon={LayoutGrid}
1482
+ title={hasCollectionItem ? 'No notes in this folder' : 'No notes yet'}
1483
+ description={
1484
+ hasCollectionItem
1485
+ ? `Create the first note in ${currentFolderLabel}.`
1486
+ : 'Folders live in the sidebar. Create a note here to get started.'
1487
+ }
1488
+ action={
1489
+ <Button
1490
+ size="sm"
1491
+ onClick={() => void onCreateDocument()}
1492
+ disabled={creatingDocument}
1493
+ className="gap-1.5"
1494
+ >
1495
+ <Plus className="h-3.5 w-3.5" />
1496
+ {creatingDocument ? 'Creating…' : 'New note'}
1497
+ </Button>
1498
+ }
1499
+ />
1500
+ );
1501
+ }
1502
+
1503
+ 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">
1505
+ {notes.map((note) => (
1506
+ <NoteCard
1507
+ key={note.id}
1508
+ note={note}
1509
+ statusPropertyName={statusPropertyName}
1510
+ onOpen={() => onOpenNote(note)}
1511
+ />
1512
+ ))}
1513
+ </div>
1514
+ );
1515
+ }
1516
+
1517
+ function NoteCard({
1518
+ note,
1519
+ statusPropertyName,
1520
+ onOpen,
1521
+ }: {
1522
+ note: DocumentRecord;
1523
+ statusPropertyName: string | null;
1524
+ onOpen: () => void;
1525
+ }) {
1526
+ const status = statusPropertyName ? getStatusLabel(note.properties[statusPropertyName]) : null;
1527
+ const coverUrl = getCoverUrl(note);
1528
+ const title = getNoteTitle(note);
1529
+ const previewText = getNotePreviewText(note);
1530
+
1531
+ return (
1532
+ <button
1533
+ type="button"
1534
+ onClick={onOpen}
1535
+ className={cn(
1536
+ 'group flex h-full w-full flex-col overflow-hidden rounded-xl border border-border bg-card text-left',
1537
+ 'transition-colors hover:border-foreground/20 hover:shadow-sm',
1538
+ )}
1539
+ >
1540
+ <div className="relative aspect-[16/10] w-full overflow-hidden">
1541
+ {coverUrl ? (
1542
+ // eslint-disable-next-line @next/next/no-img-element
1543
+ <img src={coverUrl} alt="" className="h-full w-full object-cover" />
1544
+ ) : (
1545
+ <div className="flex h-full w-full bg-card px-5 py-5 text-foreground sm:px-6">
1546
+ <p className="line-clamp-5 text-xl font-semibold leading-snug text-foreground/75 sm:text-2xl">
1547
+ {previewText}
1548
+ </p>
1549
+ </div>
1550
+ )}
1551
+ {status ? (
1552
+ <div className="absolute right-2 top-2">
1553
+ <StatusPill status={status} />
1554
+ </div>
1555
+ ) : null}
1556
+ </div>
1557
+ <div className="flex w-full items-center gap-1.5 border-t border-border px-3 py-2">
1558
+ <NoteIcon icon={note.icon} className="shrink-0 text-muted-foreground" />
1559
+ <span className="line-clamp-1 text-[12.5px] text-foreground">
1560
+ {title}
1561
+ </span>
1562
+ </div>
1563
+ </button>
1564
+ );
1565
+ }
1566
+
1567
+ /* -------------------------------------------------------------------------- */
1568
+ /* Table view */
1569
+ /* -------------------------------------------------------------------------- */
1570
+
1571
+ function TableBody({
1572
+ notes,
1573
+ metadataProperties,
1574
+ folderOptions,
1575
+ folderNameById,
1576
+ folderPropertyName,
1577
+ savingNoteId,
1578
+ onOpen,
1579
+ onSaveTitle,
1580
+ onSaveProperties,
1581
+ currentFolderLabel,
1582
+ hasCollectionItem,
1583
+ multiSelect,
1584
+ rowRefs,
1585
+ }: {
1586
+ notes: DocumentRecord[];
1587
+ metadataProperties: DatabaseProperty[];
1588
+ folderOptions: FolderOption[];
1589
+ folderNameById: Map<string, string>;
1590
+ folderPropertyName: string;
1591
+ savingNoteId: string | null;
1592
+ onOpen: (doc: DocumentRecord) => void;
1593
+ onSaveTitle: (id: string, title: string) => void;
1594
+ onSaveProperties: (id: string, props: Record<string, unknown>) => void;
1595
+ currentFolderLabel: string;
1596
+ hasCollectionItem: boolean;
1597
+ multiSelect: MultiSelectController<DocumentRecord>;
1598
+ rowRefs: Map<string, HTMLTableRowElement>;
1599
+ }) {
1600
+ const columns = metadataProperties.slice(0, 6);
1601
+ const allSelected =
1602
+ notes.length > 0 && notes.every((note) => multiSelect.isSelected(note.id));
1603
+ const handleSelectAllToggle = () => {
1604
+ if (allSelected) {
1605
+ multiSelect.clear();
1606
+ return;
1607
+ }
1608
+ multiSelect.select(notes.map((note) => note.id));
1609
+ };
1610
+
1611
+ return (
1612
+ <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
+ {!notes.length ? (
1629
+ <EmptyState
1630
+ icon={Table2}
1631
+ title={hasCollectionItem ? 'No rows in this folder' : 'No rows yet'}
1632
+ description={
1633
+ hasCollectionItem
1634
+ ? `Create a note in ${currentFolderLabel} to start editing metadata here.`
1635
+ : 'Notes become rows here. Create one to begin editing metadata inline.'
1636
+ }
1637
+ />
1638
+ ) : (
1639
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
1640
+ <div {...multiSelect.getContainerProps()} className="overflow-x-auto">
1641
+ <table className="w-full min-w-[1040px] border-collapse text-[13px]">
1642
+ <thead>
1643
+ <tr className="border-b border-border bg-muted/40">
1644
+ <th className="w-10 px-3 py-2.5">
1645
+ <MultiSelectCheckbox
1646
+ isSelected={allSelected}
1647
+ onClick={(e) => {
1648
+ e.stopPropagation();
1649
+ handleSelectAllToggle();
1650
+ }}
1651
+ alwaysVisible
1652
+ ariaLabel={allSelected ? 'Deselect all notes' : 'Select all notes'}
1653
+ />
1654
+ </th>
1655
+ <th className="px-3 py-2.5 text-left">
1656
+ <div className="flex items-center gap-1.5">
1657
+ <FileText className="h-3 w-3 text-muted-foreground" />
1658
+ <Eyebrow>Note</Eyebrow>
1659
+ </div>
1660
+ </th>
1661
+ {columns.map((p) => (
1662
+ <th key={p.name} className="border-l border-border px-3 py-2.5 text-left">
1663
+ <Eyebrow>{p.name}</Eyebrow>
1664
+ </th>
1665
+ ))}
1666
+ <th className="w-10 border-l border-border" />
1667
+ </tr>
1668
+ </thead>
1669
+ <tbody>
1670
+ {notes.map((note) => (
1671
+ <TableRow
1672
+ key={note.id}
1673
+ note={note}
1674
+ columns={columns}
1675
+ folderOptions={folderOptions}
1676
+ folderNameById={folderNameById}
1677
+ folderPropertyName={folderPropertyName}
1678
+ saving={savingNoteId === note.id}
1679
+ onOpen={() => onOpen(note)}
1680
+ onSaveTitle={(title) => onSaveTitle(note.id, title)}
1681
+ onSaveProperties={(props) => onSaveProperties(note.id, props)}
1682
+ isSelected={multiSelect.isSelected(note.id)}
1683
+ onCheckboxClick={multiSelect.onCheckboxClick(note.id)}
1684
+ onRowMouseDown={multiSelect.onRowMouseDown(note.id)}
1685
+ rowProps={multiSelect.getRowProps(note.id)}
1686
+ rowRef={(node) => {
1687
+ if (node) {
1688
+ rowRefs.set(note.id, node);
1689
+ } else {
1690
+ rowRefs.delete(note.id);
1691
+ }
1692
+ }}
1693
+ />
1694
+ ))}
1695
+ </tbody>
1696
+ </table>
1697
+ </div>
1698
+ </div>
1699
+ )}
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
+ </div>
1718
+ );
1719
+ }
1720
+
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
+ function TableRow({
1740
+ note,
1741
+ columns,
1742
+ folderOptions,
1743
+ folderNameById,
1744
+ folderPropertyName,
1745
+ saving,
1746
+ onOpen,
1747
+ onSaveTitle,
1748
+ onSaveProperties,
1749
+ isSelected,
1750
+ onCheckboxClick,
1751
+ onRowMouseDown,
1752
+ rowProps,
1753
+ rowRef,
1754
+ }: {
1755
+ note: DocumentRecord;
1756
+ columns: DatabaseProperty[];
1757
+ folderOptions: FolderOption[];
1758
+ folderNameById: Map<string, string>;
1759
+ folderPropertyName: string;
1760
+ saving: boolean;
1761
+ onOpen: () => void;
1762
+ onSaveTitle: (title: string) => void;
1763
+ onSaveProperties: (props: Record<string, unknown>) => void;
1764
+ isSelected: boolean;
1765
+ onCheckboxClick: (event: React.MouseEvent) => void;
1766
+ onRowMouseDown: (event: React.MouseEvent) => void;
1767
+ rowProps: Record<string, string>;
1768
+ rowRef: (node: HTMLTableRowElement | null) => void;
1769
+ }) {
1770
+ return (
1771
+ <tr
1772
+ {...rowProps}
1773
+ ref={rowRef}
1774
+ className={cn(
1775
+ 'group border-b border-border last:border-b-0 transition-colors',
1776
+ saving ? 'bg-muted/20' : 'hover:bg-muted/30',
1777
+ isSelected && 'bg-primary/5 hover:bg-primary/10',
1778
+ )}
1779
+ onClick={onOpen}
1780
+ onMouseDown={onRowMouseDown}
1781
+ onKeyDown={(e) => {
1782
+ if (e.key === 'Enter') {
1783
+ e.preventDefault();
1784
+ onOpen();
1785
+ }
1786
+ }}
1787
+ tabIndex={0}
1788
+ >
1789
+ <td className="px-3 py-2">
1790
+ <MultiSelectCheckbox
1791
+ isSelected={isSelected}
1792
+ onClick={onCheckboxClick}
1793
+ alwaysVisible={isSelected}
1794
+ className={cn(
1795
+ 'transition-opacity',
1796
+ !isSelected && 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
1797
+ )}
1798
+ />
1799
+ </td>
1800
+ <td className="px-3 py-2">
1801
+ <div className="flex items-center gap-2">
1802
+ <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md bg-muted">
1803
+ <FileText className="h-3 w-3 text-muted-foreground" />
1804
+ </div>
1805
+ <input
1806
+ type="text"
1807
+ defaultValue={getNoteTitle(note)}
1808
+ key={`${note.id}:title:${getNoteTitle(note)}`}
1809
+ onClick={(e) => e.stopPropagation()}
1810
+ onKeyDown={(e) => {
1811
+ if (e.key === 'Enter') e.currentTarget.blur();
1812
+ }}
1813
+ onBlur={(e) => onSaveTitle(e.target.value)}
1814
+ className="w-full rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[13px] font-medium text-foreground outline-none transition-colors focus:border-border focus:bg-background"
1815
+ />
1816
+ <ArrowUpRight className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-60" />
1817
+ </div>
1818
+ </td>
1819
+ {columns.map((prop) => (
1820
+ <td key={prop.name} className="border-l border-border px-3 py-2 align-middle">
1821
+ <TableCell
1822
+ note={note}
1823
+ property={prop}
1824
+ folderOptions={folderOptions}
1825
+ folderNameById={folderNameById}
1826
+ folderPropertyName={folderPropertyName}
1827
+ onSaveProperties={onSaveProperties}
1828
+ />
1829
+ </td>
1830
+ ))}
1831
+ <td className="w-10 border-l border-border" />
1832
+ </tr>
1833
+ );
1834
+ }
1835
+
1836
+ function TableCell({
1837
+ note,
1838
+ property,
1839
+ folderOptions,
1840
+ folderNameById,
1841
+ folderPropertyName,
1842
+ onSaveProperties,
1843
+ }: {
1844
+ note: DocumentRecord;
1845
+ property: DatabaseProperty;
1846
+ folderOptions: FolderOption[];
1847
+ folderNameById: Map<string, string>;
1848
+ folderPropertyName: string;
1849
+ onSaveProperties: (props: Record<string, unknown>) => void;
1850
+ }) {
1851
+ const value = note.properties[property.name];
1852
+ const selectClass =
1853
+ 'w-full appearance-none rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[12px] text-foreground outline-none transition-colors focus:border-border focus:bg-background';
1854
+
1855
+ if (property.type === 'status' || property.type === 'select') {
1856
+ const label = isPresentString(value) ? value : '';
1857
+ return (
1858
+ <div className="relative inline-flex items-center" onClick={(e) => e.stopPropagation()}>
1859
+ {label ? (
1860
+ <StatusPill status={label} />
1861
+ ) : (
1862
+ <span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">None</span>
1863
+ )}
1864
+ <select
1865
+ aria-label={property.name}
1866
+ className="absolute inset-0 cursor-pointer opacity-0"
1867
+ value={label}
1868
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.value || null })}
1869
+ >
1870
+ <option value="">None</option>
1871
+ {(property.options ?? []).map((opt) => (
1872
+ <option key={opt.id ?? opt.name} value={opt.name}>
1873
+ {opt.name}
1874
+ </option>
1875
+ ))}
1876
+ </select>
1877
+ </div>
1878
+ );
1879
+ }
1880
+
1881
+ if (property.type === 'date') {
1882
+ return (
1883
+ <input
1884
+ type="date"
1885
+ value={getDateInputValue(value)}
1886
+ onClick={(e) => e.stopPropagation()}
1887
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.value || null })}
1888
+ className={cn(selectClass, 'text-[12px]')}
1889
+ />
1890
+ );
1891
+ }
1892
+
1893
+ if (property.type === 'checkbox') {
1894
+ return (
1895
+ <label
1896
+ className="inline-flex items-center"
1897
+ onClick={(e) => e.stopPropagation()}
1898
+ >
1899
+ <input
1900
+ type="checkbox"
1901
+ checked={Boolean(value)}
1902
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.checked })}
1903
+ className="h-3.5 w-3.5 rounded border-border"
1904
+ />
1905
+ </label>
1906
+ );
1907
+ }
1908
+
1909
+ if (property.type === 'relation' && property.name === folderPropertyName) {
1910
+ const currentId = getRelationIds(value)[0] ?? '';
1911
+ return (
1912
+ <select
1913
+ className={cn(selectClass, 'text-[12px]')}
1914
+ value={currentId}
1915
+ onClick={(e) => e.stopPropagation()}
1916
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.value ? [e.target.value] : [] })}
1917
+ >
1918
+ <option value="">No folder</option>
1919
+ {folderOptions.map((f) => (
1920
+ <option key={f.id} value={f.id}>
1921
+ {f.pathLabel}
1922
+ </option>
1923
+ ))}
1924
+ </select>
1925
+ );
1926
+ }
1927
+
1928
+ if (property.type === 'number') {
1929
+ return (
1930
+ <input
1931
+ type="number"
1932
+ defaultValue={typeof value === 'number' ? String(value) : ''}
1933
+ inputMode="decimal"
1934
+ key={`${note.id}:${property.name}:${String(value ?? '')}`}
1935
+ onClick={(e) => e.stopPropagation()}
1936
+ onKeyDown={(e) => {
1937
+ if (e.key === 'Enter') e.currentTarget.blur();
1938
+ }}
1939
+ onBlur={(e) => {
1940
+ const v = e.target.value.trim();
1941
+ onSaveProperties({ [property.name]: v ? Number(v) : null });
1942
+ }}
1943
+ className={cn(selectClass, 'font-mono text-[12px]')}
1944
+ />
1945
+ );
1946
+ }
1947
+
1948
+ if (property.type === 'rich_text') {
1949
+ return (
1950
+ <input
1951
+ type="text"
1952
+ defaultValue={isPresentString(value) ? value : ''}
1953
+ key={`${note.id}:${property.name}:${String(value ?? '')}`}
1954
+ onClick={(e) => e.stopPropagation()}
1955
+ onKeyDown={(e) => {
1956
+ if (e.key === 'Enter') e.currentTarget.blur();
1957
+ }}
1958
+ onBlur={(e) => {
1959
+ const v = e.target.value.trim();
1960
+ onSaveProperties({ [property.name]: v || null });
1961
+ }}
1962
+ className={cn(selectClass, 'text-[12px]')}
1963
+ />
1964
+ );
1965
+ }
1966
+
1967
+ return (
1968
+ <span className="px-1.5 text-[12px] text-muted-foreground">
1969
+ {formatPropertyValue(value, property, folderNameById)}
1970
+ </span>
1971
+ );
1972
+ }
1973
+
1974
+ /* -------------------------------------------------------------------------- */
1975
+ /* Calendar view */
1976
+ /* -------------------------------------------------------------------------- */
1977
+
1978
+ function CalendarBody({
1979
+ notes,
1980
+ monthDays,
1981
+ visibleMonth,
1982
+ setVisibleMonth,
1983
+ notesByDay,
1984
+ statusPropertyName,
1985
+ calendarPropertyName,
1986
+ dateProperties,
1987
+ activeDateProperty,
1988
+ setActiveDateProperty,
1989
+ scheduledNotesCount,
1990
+ onOpen,
1991
+ }: {
1992
+ notes: DocumentRecord[];
1993
+ monthDays: Date[];
1994
+ visibleMonth: Date;
1995
+ setVisibleMonth: (d: Date) => void;
1996
+ notesByDay: Map<string, DocumentRecord[]>;
1997
+ statusPropertyName: string | null;
1998
+ calendarPropertyName: string;
1999
+ dateProperties: DatabaseProperty[];
2000
+ activeDateProperty: string;
2001
+ setActiveDateProperty: (v: string) => void;
2002
+ scheduledNotesCount: number;
2003
+ onOpen: (doc: DocumentRecord) => void;
2004
+ }) {
2005
+ return (
2006
+ <div className="flex flex-1 flex-col gap-4 px-6 py-5">
2007
+ <div className="flex flex-wrap items-center gap-2">
2008
+ <div className="inline-flex items-center gap-0 rounded-lg border border-border bg-background p-0.5">
2009
+ <Button
2010
+ variant="ghost"
2011
+ size="icon"
2012
+ className="h-7 w-7"
2013
+ onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1))}
2014
+ >
2015
+ <ChevronLeft className="h-3.5 w-3.5" />
2016
+ </Button>
2017
+ <div className="px-2 text-[13px] font-semibold tracking-tight text-foreground">
2018
+ {formatMonthLabel(visibleMonth)}
2019
+ </div>
2020
+ <Button
2021
+ variant="ghost"
2022
+ size="icon"
2023
+ className="h-7 w-7"
2024
+ onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1))}
2025
+ >
2026
+ <ChevronRight className="h-3.5 w-3.5" />
2027
+ </Button>
2028
+ </div>
2029
+ <Button variant="outline" size="sm" className="h-8 text-[12px]" onClick={() => setVisibleMonth(new Date())}>
2030
+ Today
2031
+ </Button>
2032
+
2033
+ <div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px]">
2034
+ <CalendarDays className="h-3 w-3 text-muted-foreground" />
2035
+ <span className="text-muted-foreground">Date field</span>
2036
+ <select
2037
+ className="appearance-none bg-transparent pr-3 font-semibold text-foreground outline-none"
2038
+ value={calendarPropertyName}
2039
+ onChange={(e) => setActiveDateProperty(e.target.value)}
2040
+ disabled={!dateProperties.length}
2041
+ >
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
+ )}
2051
+ </select>
2052
+ <ChevronDown className="h-3 w-3 text-muted-foreground" />
2053
+ </div>
2054
+
2055
+ <div className="flex-1" />
2056
+ <Eyebrow>{pluralize(scheduledNotesCount, 'note')} scheduled</Eyebrow>
2057
+ </div>
2058
+
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">
2067
+ <div className="grid grid-cols-7 border-b border-border bg-muted/40">
2068
+ {WEEKDAY_LABELS.map((label, i) => (
2069
+ <div
2070
+ key={label}
2071
+ className={cn(
2072
+ 'px-3 py-2 font-mono text-[10px] font-medium uppercase tracking-[0.14em]',
2073
+ i >= 5 ? 'text-muted-foreground/60' : 'text-muted-foreground',
2074
+ )}
2075
+ >
2076
+ {label}
2077
+ </div>
2078
+ ))}
2079
+ </div>
2080
+ <div className="grid grid-cols-7">
2081
+ {monthDays.map((day) => {
2082
+ const key = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`;
2083
+ const dayNotes = notesByDay.get(key) ?? [];
2084
+ const inMonth = isSameMonth(day, visibleMonth);
2085
+ const today = isToday(day);
2086
+ return (
2087
+ <div
2088
+ key={key}
2089
+ className={cn(
2090
+ 'flex min-h-[110px] flex-col gap-1.5 border-b border-r border-border px-2 py-2',
2091
+ !inMonth && 'bg-muted/30',
2092
+ )}
2093
+ >
2094
+ <div className="flex items-center justify-between">
2095
+ {today ? (
2096
+ <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-foreground text-[11px] font-semibold text-background">
2097
+ {day.getDate()}
2098
+ </span>
2099
+ ) : (
2100
+ <span
2101
+ className={cn(
2102
+ 'text-[12px] font-medium',
2103
+ inMonth ? 'text-foreground/80' : 'text-muted-foreground/50',
2104
+ )}
2105
+ >
2106
+ {day.getDate()}
2107
+ </span>
2108
+ )}
2109
+ {today ? <Eyebrow className="text-foreground">Today</Eyebrow> : null}
2110
+ </div>
2111
+ <div className="flex flex-col gap-1">
2112
+ {dayNotes.slice(0, 3).map((note) => {
2113
+ const status = statusPropertyName
2114
+ ? getStatusLabel(note.properties[statusPropertyName])
2115
+ : null;
2116
+ const tone = getStatusTone(status);
2117
+ return (
2118
+ <button
2119
+ key={note.id}
2120
+ type="button"
2121
+ onClick={() => onOpen(note)}
2122
+ className={cn(
2123
+ 'line-clamp-1 rounded-md border border-l-2 border-border bg-background px-2 py-1 text-left text-[11px] font-medium text-foreground transition-colors hover:bg-muted/50',
2124
+ statusBarClasses[tone],
2125
+ )}
2126
+ >
2127
+ {getNoteTitle(note)}
2128
+ </button>
2129
+ );
2130
+ })}
2131
+ {dayNotes.length > 3 ? (
2132
+ <span className="px-1 text-[10px] text-muted-foreground">+{dayNotes.length - 3} more</span>
2133
+ ) : null}
2134
+ </div>
2135
+ </div>
2136
+ );
2137
+ })}
2138
+ </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
+ </div>
2157
+ </div>
2158
+ );
2159
+ }
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
+ }