@notis_ai/cli 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -18
- package/config/notis_app_boundary_rules.json +50 -0
- package/dist/scaffolds/notis-database/app/globals.css +44 -0
- package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
- package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
- package/dist/scaffolds/notis-database/index.html +12 -0
- package/dist/scaffolds/notis-database/lib/types.ts +134 -0
- package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-database/notis.config.ts +27 -0
- package/dist/scaffolds/notis-database/package.json +31 -0
- package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
- package/dist/scaffolds/notis-database/src/mock-runtime.ts +557 -0
- package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
- package/dist/scaffolds/{notes → notis-database}/tsconfig.json +2 -1
- package/dist/scaffolds/notis-database/vite.config.ts +22 -0
- package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
- package/dist/scaffolds/{notes → notis-notes}/app/page.tsx +783 -80
- package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-notes/components.json +20 -0
- package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/{notes → notis-notes}/notis.config.ts +12 -7
- package/dist/scaffolds/{notes → notis-notes}/package.json +2 -2
- package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
- package/dist/scaffolds/notis-random/README.md +33 -0
- package/dist/scaffolds/notis-random/app/globals.css +11 -0
- package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
- package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
- package/dist/scaffolds/notis-random/app/page.tsx +222 -0
- package/dist/scaffolds/notis-random/index.html +12 -0
- package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
- package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
- package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
- package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
- package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-random/notis.config.ts +43 -0
- package/dist/scaffolds/notis-random/package.json +32 -0
- package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
- package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
- package/dist/scaffolds/notis-random/src/mock-runtime.ts +128 -0
- package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
- package/dist/scaffolds/notis-random/tsconfig.json +23 -0
- package/dist/scaffolds/notis-random/vite.config.ts +11 -0
- package/dist/scaffolds.json +27 -4
- package/package.json +3 -1
- package/skills/notis-apps/SKILL.md +2 -2
- package/skills/notis-cli/SKILL.md +18 -16
- package/skills/notis-query/cli.md +9 -10
- package/src/cli.js +30 -2
- package/src/command-specs/apps.js +42 -15
- package/src/command-specs/helpers.js +73 -45
- package/src/command-specs/meta.js +7 -7
- package/src/command-specs/tools.js +196 -83
- package/src/runtime/app-boundary-validator.js +65 -14
- package/src/runtime/profiles.js +10 -4
- package/src/runtime/transport.js +122 -37
- package/template/app/page.tsx +1 -1
- package/template/notis.config.ts +1 -1
- package/template/packages/sdk/src/hooks/useTool.ts +1 -1
- /package/dist/scaffolds/{notes → notis-database}/app/layout.tsx +0 -0
- /package/dist/scaffolds/{notes → notis-database}/components/ui/badge.tsx +0 -0
- /package/dist/scaffolds/{notes → notis-database}/components/ui/button.tsx +0 -0
- /package/dist/scaffolds/{notes → notis-database}/components/ui/card.tsx +0 -0
- /package/dist/scaffolds/{notes → notis-database}/components.json +0 -0
- /package/dist/scaffolds/{notes → notis-database}/lib/utils.ts +0 -0
- /package/dist/scaffolds/{notes → notis-database}/postcss.config.mjs +0 -0
- /package/dist/scaffolds/{notes → notis-notes}/app/globals.css +0 -0
- /package/dist/scaffolds/{notes → notis-notes}/tailwind.config.ts +0 -0
- /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
|
-
|
|
11
|
+
useNotisNavigation,
|
|
12
12
|
useTopBarSearch,
|
|
13
|
-
|
|
14
|
-
type DatabaseProperty,
|
|
15
|
-
type DocumentRecord,
|
|
13
|
+
useTool,
|
|
16
14
|
type MultiSelectController,
|
|
17
15
|
} from '@notis/sdk';
|
|
18
|
-
import {
|
|
19
|
-
|
|
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:
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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:
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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:
|
|
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
|
-
|
|
455
|
-
|
|
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,
|
|
491
|
-
const
|
|
492
|
-
const {
|
|
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
|
|
503
|
-
const
|
|
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 =
|
|
528
|
-
const foldersQuery =
|
|
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 =
|
|
573
|
-
const topLevelError = errorMessage || notesQuery.error?.message || foldersQuery.error?.message;
|
|
574
|
-
const pageTitle =
|
|
575
|
-
const pageIcon =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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" />
|