@notis_ai/cli 0.2.2 → 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 (81) hide show
  1. package/README.md +19 -18
  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/page.tsx +1091 -0
  5. package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
  6. package/dist/scaffolds/notis-database/index.html +12 -0
  7. package/dist/scaffolds/notis-database/lib/types.ts +134 -0
  8. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  9. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  10. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  11. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  12. package/dist/scaffolds/notis-database/notis.config.ts +27 -0
  13. package/dist/scaffolds/notis-database/package.json +31 -0
  14. package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
  15. package/dist/scaffolds/notis-database/src/mock-runtime.ts +557 -0
  16. package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
  17. package/dist/scaffolds/{notes → notis-database}/tsconfig.json +2 -1
  18. package/dist/scaffolds/notis-database/vite.config.ts +22 -0
  19. package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
  20. package/dist/scaffolds/{notes → notis-notes}/app/page.tsx +783 -80
  21. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
  22. package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
  23. package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
  24. package/dist/scaffolds/notis-notes/components.json +20 -0
  25. package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
  26. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  27. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  28. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  29. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  30. package/dist/scaffolds/{notes → notis-notes}/notis.config.ts +12 -7
  31. package/dist/scaffolds/{notes → notis-notes}/package.json +2 -2
  32. package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
  33. package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
  34. package/dist/scaffolds/notis-random/README.md +33 -0
  35. package/dist/scaffolds/notis-random/app/globals.css +11 -0
  36. package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
  37. package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
  38. package/dist/scaffolds/notis-random/app/page.tsx +222 -0
  39. package/dist/scaffolds/notis-random/index.html +12 -0
  40. package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
  41. package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
  42. package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
  43. package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
  44. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  45. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  46. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  47. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  48. package/dist/scaffolds/notis-random/notis.config.ts +43 -0
  49. package/dist/scaffolds/notis-random/package.json +32 -0
  50. package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
  51. package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
  52. package/dist/scaffolds/notis-random/src/mock-runtime.ts +128 -0
  53. package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
  54. package/dist/scaffolds/notis-random/tsconfig.json +23 -0
  55. package/dist/scaffolds/notis-random/vite.config.ts +11 -0
  56. package/dist/scaffolds.json +27 -4
  57. package/package.json +2 -1
  58. package/skills/notis-apps/SKILL.md +2 -2
  59. package/skills/notis-cli/SKILL.md +18 -16
  60. package/skills/notis-query/cli.md +9 -10
  61. package/src/cli.js +30 -2
  62. package/src/command-specs/apps.js +42 -15
  63. package/src/command-specs/helpers.js +73 -45
  64. package/src/command-specs/meta.js +7 -7
  65. package/src/command-specs/tools.js +196 -83
  66. package/src/runtime/app-boundary-validator.js +65 -14
  67. package/src/runtime/profiles.js +10 -4
  68. package/src/runtime/transport.js +122 -37
  69. package/template/app/page.tsx +1 -1
  70. package/template/notis.config.ts +1 -1
  71. package/template/packages/sdk/src/hooks/useTool.ts +1 -1
  72. /package/dist/scaffolds/{notes → notis-database}/app/layout.tsx +0 -0
  73. /package/dist/scaffolds/{notes → notis-database}/components/ui/badge.tsx +0 -0
  74. /package/dist/scaffolds/{notes → notis-database}/components/ui/button.tsx +0 -0
  75. /package/dist/scaffolds/{notes → notis-database}/components/ui/card.tsx +0 -0
  76. /package/dist/scaffolds/{notes → notis-database}/components.json +0 -0
  77. /package/dist/scaffolds/{notes → notis-database}/lib/utils.ts +0 -0
  78. /package/dist/scaffolds/{notes → notis-database}/postcss.config.mjs +0 -0
  79. /package/dist/scaffolds/{notes → notis-notes}/app/globals.css +0 -0
  80. /package/dist/scaffolds/{notes → notis-notes}/tailwind.config.ts +0 -0
  81. /package/dist/scaffolds/{notes → notis-notes}/vite.config.ts +0 -0
@@ -1,45 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import {
5
5
  MultiSelectActionBar,
6
6
  MultiSelectCheckbox,
7
7
  MultiSelectDragOverlay,
8
- useDatabase,
9
8
  useMultiSelect,
9
+ useBackend,
10
10
  useNotis,
11
- useNotisRuntime,
11
+ useNotisNavigation,
12
12
  useTopBarSearch,
13
- useUpsertDocument,
14
- type DatabaseProperty,
15
- type DocumentRecord,
13
+ useTool,
16
14
  type MultiSelectController,
17
15
  } from '@notis/sdk';
18
- import {
19
- ArrowUpDown,
20
- ArrowUpRight,
21
- Boxes,
22
- CalendarDays,
23
- ChevronDown,
24
- ChevronLeft,
25
- ChevronRight,
26
- Eye,
27
- FileText,
28
- FolderMinus,
29
- LayoutGrid,
30
- Link2,
31
- Loader2,
32
- Maximize2,
33
- Plus,
34
- Rows3,
35
- Search,
36
- Settings2,
37
- SlidersHorizontal,
38
- Sparkles,
39
- Table2,
40
- type LucideIcon,
41
- } from 'lucide-react';
42
- import { DynamicIcon, type IconName } from 'lucide-react/dynamic';
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';
43
18
 
44
19
  import { Button } from '@/components/ui/button';
45
20
  import { cn } from '@/lib/utils';
@@ -49,7 +24,7 @@ type TabKey = 'gallery' | 'table' | 'calendar';
49
24
  type TabConfig = {
50
25
  key: TabKey;
51
26
  label: string;
52
- icon: LucideIcon;
27
+ icon: Icon;
53
28
  };
54
29
 
55
30
  type FolderOption = {
@@ -59,6 +34,118 @@ type FolderOption = {
59
34
  pathLabel: string;
60
35
  };
61
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
+
62
149
  const NOTE_DATABASE_SLUG = 'notes';
63
150
  const FOLDER_DATABASE_SLUG = 'note_folders';
64
151
  const DEFAULT_NOTE_TITLE = 'Untitled note';
@@ -70,10 +157,304 @@ const TABS: TabConfig[] = [
70
157
  { key: 'calendar', label: 'Calendar', icon: CalendarDays },
71
158
  ];
72
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
+
73
242
  function isPresentString(value: unknown): value is string {
74
243
  return typeof value === 'string' && value.trim().length > 0;
75
244
  }
76
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
+
77
458
  function pluralize(count: number, singular: string, plural = `${singular}s`): string {
78
459
  return `${count} ${count === 1 ? singular : plural}`;
79
460
  }
@@ -184,17 +565,9 @@ function NoteIcon({
184
565
  className?: string;
185
566
  fallbackClassName?: string;
186
567
  }) {
187
- if (typeof icon === 'string' && icon.startsWith('lucide:')) {
188
- const name = icon.slice('lucide:'.length).trim().replace(/_/g, '-');
189
- if (name) {
190
- return (
191
- <DynamicIcon
192
- name={name as IconName}
193
- className={cn('h-3.5 w-3.5', className)}
194
- fallback={() => <FileText className={cn('h-3.5 w-3.5', className, fallbackClassName)} />}
195
- />
196
- );
197
- }
568
+ const Icon = getPhosphorIconComponent(icon);
569
+ if (Icon) {
570
+ return <Icon className={cn('h-3.5 w-3.5', className)} />;
198
571
  }
199
572
  if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
200
573
  return (
@@ -342,7 +715,7 @@ function EmptyState({
342
715
  description,
343
716
  action,
344
717
  }: {
345
- icon: LucideIcon;
718
+ icon: Icon;
346
719
  title: string;
347
720
  description: string;
348
721
  action?: React.ReactNode;
@@ -367,17 +740,9 @@ function LoadingState() {
367
740
  }
368
741
 
369
742
  function PageIcon({ icon }: { icon: string | null | undefined }) {
370
- if (typeof icon === 'string' && icon.startsWith('lucide:')) {
371
- const name = icon.slice('lucide:'.length).trim().replace(/_/g, '-');
372
- if (name) {
373
- return (
374
- <DynamicIcon
375
- name={name as IconName}
376
- className="h-7 w-7 stroke-[1.5] text-foreground"
377
- fallback={() => <Boxes className="h-7 w-7 stroke-[1.5] text-foreground" />}
378
- />
379
- );
380
- }
743
+ const Icon = getPhosphorIconComponent(icon);
744
+ if (Icon) {
745
+ return <Icon className="h-7 w-7 stroke-[1.5] text-foreground" />;
381
746
  }
382
747
  if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
383
748
  return (
@@ -391,11 +756,138 @@ function PageIcon({ icon }: { icon: string | null | undefined }) {
391
756
  return <Boxes className="h-7 w-7 stroke-[1.5] text-foreground" />;
392
757
  }
393
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
+
394
886
  function ToolbarIconButton({
395
887
  icon: Icon,
396
888
  label,
397
889
  }: {
398
- icon: LucideIcon;
890
+ icon: Icon;
399
891
  label: string;
400
892
  }) {
401
893
  return (
@@ -441,18 +933,97 @@ function ViewPill({
441
933
  function PageHeader({
442
934
  title,
443
935
  icon,
936
+ storedIcon = null,
937
+ iconEditable = false,
938
+ iconSaving = false,
939
+ titleEditable = false,
940
+ titleEditing = false,
941
+ titleDraft = '',
942
+ titleSaving = false,
444
943
  activeTab,
445
944
  onTabChange,
945
+ onIconChange,
946
+ onIconRemove,
947
+ onStartTitleEdit,
948
+ onTitleDraftChange,
949
+ onSubmitTitleEdit,
950
+ onCancelTitleEdit,
446
951
  }: {
447
952
  title: string;
448
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;
449
961
  activeTab: TabKey;
450
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;
451
969
  }) {
452
970
  return (
453
971
  <div className="flex flex-col gap-3 px-6 pt-6">
454
- <PageIcon icon={icon} />
455
- <h1 className="text-3xl font-bold tracking-tight text-foreground">{title}</h1>
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
+ )}
456
1027
  <div className="flex flex-wrap items-center gap-2 border-b border-border pb-2">
457
1028
  <div className="flex items-center gap-1">
458
1029
  {TABS.map((tab) => (
@@ -487,20 +1058,29 @@ function PageHeader({
487
1058
  }
488
1059
 
489
1060
  export default function NotesPage() {
490
- const { app, route, databases, collectionItem } = useNotis();
491
- const runtime = useNotisRuntime();
492
- const { upsert } = useUpsertDocument();
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');
493
1065
 
494
1066
  const [activeTab, setActiveTab] = useState<TabKey>('gallery');
495
1067
  const [activeDateProperty, setActiveDateProperty] = useState('');
496
1068
  const [visibleMonth, setVisibleMonth] = useState(() => new Date());
497
1069
  const [creatingDocument, setCreatingDocument] = useState(false);
498
1070
  const [savingNoteId, setSavingNoteId] = useState<string | null>(null);
1071
+ const [savingFolderTitle, setSavingFolderTitle] = useState(false);
1072
+ const [savingFolderIcon, setSavingFolderIcon] = useState(false);
499
1073
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
500
1074
  const [searchQuery, setSearchQuery] = useState('');
501
-
502
- const noteDatabase = databases.find((d) => d.slug === NOTE_DATABASE_SLUG) ?? null;
503
- const noteProperties = noteDatabase?.properties ?? [];
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;
504
1084
  const titleProperty = noteProperties.find((p) => p.type === 'title');
505
1085
  const titlePropertyName = titleProperty?.name ?? 'Title';
506
1086
  const folderPropertyName = noteProperties.find((p) => p.type === 'relation')?.name ?? 'Folder';
@@ -510,6 +1090,14 @@ export default function NotesPage() {
510
1090
  const metadataProperties = noteProperties.filter((p) => p.name !== titlePropertyName);
511
1091
 
512
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;
513
1101
 
514
1102
  const notesFilter = activeFolderId
515
1103
  ? {
@@ -524,8 +1112,8 @@ export default function NotesPage() {
524
1112
  }
525
1113
  : undefined;
526
1114
 
527
- const notesQuery = useDatabase(NOTE_DATABASE_SLUG, { filter: notesFilter, pageSize: 250 });
528
- const foldersQuery = useDatabase(FOLDER_DATABASE_SLUG, { pageSize: 500 });
1115
+ const notesQuery = useQueryDocuments(NOTE_DATABASE_SLUG, { filter: notesFilter, pageSize: 250 });
1116
+ const foldersQuery = useQueryDocuments(FOLDER_DATABASE_SLUG, { pageSize: 500 });
529
1117
 
530
1118
  const { setLoading: setSearchLoading } = useTopBarSearch({
531
1119
  value: searchQuery,
@@ -568,11 +1156,18 @@ export default function NotesPage() {
568
1156
  ).length;
569
1157
 
570
1158
  const monthDays = buildCalendarDays(visibleMonth);
571
- const isLoading = notesQuery.loading || foldersQuery.loading;
572
- const currentFolderLabel = collectionItem?.title ?? 'All notes';
573
- const topLevelError = errorMessage || notesQuery.error?.message || foldersQuery.error?.message;
574
- const pageTitle = collectionItem?.title || route?.name || app?.name || 'Notes';
575
- const pageIcon = collectionItem?.icon || route?.icon || null;
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]);
576
1171
 
577
1172
  useEffect(() => {
578
1173
  if (!dateProperties.length) {
@@ -585,19 +1180,30 @@ export default function NotesPage() {
585
1180
  }, [activeDateProperty, dateProperties]);
586
1181
 
587
1182
  async function openNote(document: DocumentRecord) {
588
- runtime?.navigate?.({ kind: 'document', documentId: document.id, title: document.title });
1183
+ navigation.toDocument(document.id, document.title);
589
1184
  }
590
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
+
591
1198
  async function createDocument() {
592
1199
  setCreatingDocument(true);
593
1200
  setErrorMessage(null);
594
1201
  try {
595
- const document = await upsert({
596
- databaseSlug: NOTE_DATABASE_SLUG,
1202
+ const document = await upsertNoteDocument({
597
1203
  title: DEFAULT_NOTE_TITLE,
598
1204
  properties: activeFolderId ? { [folderPropertyName]: [activeFolderId] } : undefined,
599
1205
  });
600
- runtime?.navigate?.({ kind: 'document', documentId: document.id, title: document.title });
1206
+ navigation.toDocument(document.id, document.title);
601
1207
  } catch (error) {
602
1208
  setErrorMessage(error instanceof Error ? error.message : 'Failed to create note');
603
1209
  } finally {
@@ -609,7 +1215,7 @@ export default function NotesPage() {
609
1215
  setSavingNoteId(documentId);
610
1216
  setErrorMessage(null);
611
1217
  try {
612
- await upsert({ databaseSlug: NOTE_DATABASE_SLUG, documentId, properties });
1218
+ await upsertNoteDocument({ documentId, properties });
613
1219
  notesQuery.refetch();
614
1220
  } catch (error) {
615
1221
  setErrorMessage(error instanceof Error ? error.message : 'Failed to save note');
@@ -623,7 +1229,7 @@ export default function NotesPage() {
623
1229
  setSavingNoteId(documentId);
624
1230
  setErrorMessage(null);
625
1231
  try {
626
- await upsert({ databaseSlug: NOTE_DATABASE_SLUG, documentId, title: normalized });
1232
+ await upsertNoteDocument({ documentId, title: normalized });
627
1233
  notesQuery.refetch();
628
1234
  } catch (error) {
629
1235
  setErrorMessage(error instanceof Error ? error.message : 'Failed to rename note');
@@ -632,6 +1238,91 @@ export default function NotesPage() {
632
1238
  }
633
1239
  }
634
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
+
635
1326
  const rowRefs = useRef(new Map<string, HTMLTableRowElement>());
636
1327
  const multiSelect = useMultiSelect<DocumentRecord>({
637
1328
  items: notes,
@@ -651,8 +1342,7 @@ export default function NotesPage() {
651
1342
  try {
652
1343
  await Promise.all(
653
1344
  ids.map((documentId) =>
654
- upsert({
655
- databaseSlug: NOTE_DATABASE_SLUG,
1345
+ upsertNoteDocument({
656
1346
  documentId,
657
1347
  properties: { [folderPropertyName]: [] },
658
1348
  }),
@@ -670,6 +1360,19 @@ export default function NotesPage() {
670
1360
  <PageHeader
671
1361
  title={pageTitle}
672
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}
673
1376
  activeTab={activeTab}
674
1377
  onTabChange={setActiveTab}
675
1378
  />
@@ -1015,7 +1718,7 @@ function TableBody({
1015
1718
  );
1016
1719
  }
1017
1720
 
1018
- function FilterChip({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
1721
+ function FilterChip({ icon: Icon, label, value }: { icon: Icon; label: string; value: string }) {
1019
1722
  return (
1020
1723
  <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px]">
1021
1724
  <Icon className="h-3 w-3 text-muted-foreground" />