@notis_ai/cli 0.2.0-beta.102.1

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 (191) hide show
  1. package/README.md +334 -0
  2. package/bin/notis.js +2 -0
  3. package/config/notis_app_boundary_rules.json +50 -0
  4. package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
  5. package/dist/scaffolds/notis-database/app/globals.css +44 -0
  6. package/dist/scaffolds/notis-database/app/layout.tsx +6 -0
  7. package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
  8. package/dist/scaffolds/notis-database/components/ui/badge.tsx +28 -0
  9. package/dist/scaffolds/notis-database/components/ui/button.tsx +53 -0
  10. package/dist/scaffolds/notis-database/components/ui/card.tsx +56 -0
  11. package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
  12. package/dist/scaffolds/notis-database/components.json +20 -0
  13. package/dist/scaffolds/notis-database/index.html +12 -0
  14. package/dist/scaffolds/notis-database/lib/types.ts +134 -0
  15. package/dist/scaffolds/notis-database/lib/utils.ts +6 -0
  16. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  17. package/dist/scaffolds/notis-database/notis.config.ts +26 -0
  18. package/dist/scaffolds/notis-database/package.json +31 -0
  19. package/dist/scaffolds/notis-database/postcss.config.mjs +8 -0
  20. package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
  21. package/dist/scaffolds/notis-database/src/mock-runtime.ts +558 -0
  22. package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
  23. package/dist/scaffolds/notis-database/tsconfig.json +23 -0
  24. package/dist/scaffolds/notis-database/vite.config.ts +22 -0
  25. package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
  26. package/dist/scaffolds/notis-journal/app/globals.css +37 -0
  27. package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
  28. package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
  29. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
  30. package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
  31. package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
  32. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
  33. package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
  34. package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
  35. package/dist/scaffolds/notis-journal/components.json +20 -0
  36. package/dist/scaffolds/notis-journal/index.html +12 -0
  37. package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
  38. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  39. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  40. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  41. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  42. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  43. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  44. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
  45. package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
  46. package/dist/scaffolds/notis-journal/package.json +34 -0
  47. package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
  48. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  49. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
  50. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  51. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  52. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  53. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
  54. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
  55. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
  56. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  57. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
  58. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
  59. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  60. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
  61. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  62. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
  63. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
  64. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  65. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  66. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
  67. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
  68. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
  69. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
  70. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
  71. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
  72. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
  73. package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
  74. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
  75. package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
  76. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
  77. package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
  78. package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
  79. package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
  80. package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
  81. package/dist/scaffolds/notis-notes/app/globals.css +3 -0
  82. package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
  83. package/dist/scaffolds/notis-notes/app/page.tsx +1950 -0
  84. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
  85. package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
  86. package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
  87. package/dist/scaffolds/notis-notes/components.json +20 -0
  88. package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
  89. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  90. package/dist/scaffolds/notis-notes/notis.config.ts +35 -0
  91. package/dist/scaffolds/notis-notes/package.json +31 -0
  92. package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
  93. package/dist/scaffolds/notis-notes/tailwind.config.ts +58 -0
  94. package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
  95. package/dist/scaffolds/notis-notes/vite.config.ts +10 -0
  96. package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
  97. package/dist/scaffolds/notis-random/README.md +33 -0
  98. package/dist/scaffolds/notis-random/app/globals.css +11 -0
  99. package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
  100. package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
  101. package/dist/scaffolds/notis-random/app/page.tsx +222 -0
  102. package/dist/scaffolds/notis-random/index.html +12 -0
  103. package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
  104. package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
  105. package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
  106. package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
  107. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  108. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  109. package/dist/scaffolds/notis-random/notis.config.ts +42 -0
  110. package/dist/scaffolds/notis-random/package.json +32 -0
  111. package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
  112. package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
  113. package/dist/scaffolds/notis-random/src/mock-runtime.ts +129 -0
  114. package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
  115. package/dist/scaffolds/notis-random/tsconfig.json +23 -0
  116. package/dist/scaffolds/notis-random/vite.config.ts +11 -0
  117. package/dist/scaffolds.json +47 -0
  118. package/package.json +45 -0
  119. package/skills/notis-apps/SKILL.md +219 -0
  120. package/skills/notis-apps/cli.md +244 -0
  121. package/skills/notis-cli/SKILL.md +269 -0
  122. package/skills/notis-query/cli.md +39 -0
  123. package/src/cli.js +175 -0
  124. package/src/command-specs/apps.js +1939 -0
  125. package/src/command-specs/helpers.js +186 -0
  126. package/src/command-specs/index.js +14 -0
  127. package/src/command-specs/meta.js +161 -0
  128. package/src/command-specs/tools.js +499 -0
  129. package/src/runtime/agent-browser.js +464 -0
  130. package/src/runtime/app-boundary-validator.js +183 -0
  131. package/src/runtime/app-changelog.js +79 -0
  132. package/src/runtime/app-dev-server.js +712 -0
  133. package/src/runtime/app-dev-sessions.js +251 -0
  134. package/src/runtime/app-platform.js +1500 -0
  135. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  136. package/src/runtime/cli-mode.generated.js +4 -0
  137. package/src/runtime/cli-mode.js +29 -0
  138. package/src/runtime/desktop-auth.js +93 -0
  139. package/src/runtime/errors.js +55 -0
  140. package/src/runtime/help.js +60 -0
  141. package/src/runtime/output.js +180 -0
  142. package/src/runtime/ports.js +15 -0
  143. package/src/runtime/profiles.js +261 -0
  144. package/src/runtime/store-screenshot.js +138 -0
  145. package/src/runtime/transport.js +347 -0
  146. package/template/.harness/index.html.tmpl +333 -0
  147. package/template/CHANGELOG.md +5 -0
  148. package/template/app/globals.css +3 -0
  149. package/template/app/layout.tsx +6 -0
  150. package/template/app/page.tsx +60 -0
  151. package/template/components/ui/badge.tsx +28 -0
  152. package/template/components/ui/button.tsx +53 -0
  153. package/template/components/ui/card.tsx +56 -0
  154. package/template/components.json +20 -0
  155. package/template/lib/utils.ts +6 -0
  156. package/template/metadata/screenshot-1.png +0 -0
  157. package/template/metadata/screenshot-2.png +0 -0
  158. package/template/metadata/screenshot-3.png +0 -0
  159. package/template/notis.config.ts +36 -0
  160. package/template/package-lock.json +4137 -0
  161. package/template/package.json +32 -0
  162. package/template/packages/sdk/package.json +36 -0
  163. package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  164. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  165. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  166. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  167. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  168. package/template/packages/sdk/src/config.ts +145 -0
  169. package/template/packages/sdk/src/documents.ts +229 -0
  170. package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
  171. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  172. package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
  173. package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
  174. package/template/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  175. package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
  176. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  177. package/template/packages/sdk/src/hooks/useTool.ts +64 -0
  178. package/template/packages/sdk/src/hooks/useTools.ts +56 -0
  179. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  180. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  181. package/template/packages/sdk/src/index.ts +83 -0
  182. package/template/packages/sdk/src/provider.tsx +43 -0
  183. package/template/packages/sdk/src/runtime.ts +220 -0
  184. package/template/packages/sdk/src/styles.css +186 -0
  185. package/template/packages/sdk/src/ui.ts +15 -0
  186. package/template/packages/sdk/src/vite.ts +56 -0
  187. package/template/packages/sdk/tsconfig.json +15 -0
  188. package/template/postcss.config.mjs +8 -0
  189. package/template/tailwind.config.ts +58 -0
  190. package/template/tsconfig.json +22 -0
  191. package/template/vite.config.ts +10 -0
@@ -0,0 +1,1950 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import {
5
+ MultiSelectActionBar,
6
+ MultiSelectCheckbox,
7
+ MultiSelectDragOverlay,
8
+ useMultiSelect,
9
+ useBackend,
10
+ useDatabaseSchema,
11
+ useDocuments,
12
+ useNotis,
13
+ useNotisNavigation,
14
+ useTopBarSearch,
15
+ useUpsertDocument,
16
+ asRecord,
17
+ getDocumentPreview,
18
+ getRelationIds,
19
+ isPresentString,
20
+ optionalString,
21
+ type DatabaseProperty,
22
+ type DocumentRecord,
23
+ type MultiSelectController,
24
+ type UpsertDocumentArgs,
25
+ } from '@notis/sdk';
26
+ import { ArrowUpRightIcon as ArrowUpRight, CubeIcon as Boxes, BookOpenIcon as BookOpen, BookOpenTextIcon as BookOpenText, CalendarIcon as CalendarDays, CaretDownIcon as ChevronDown, CaretLeftIcon as ChevronLeft, CaretRightIcon as ChevronRight, FileTextIcon as FileText, FolderIcon as Folder, FolderMinusIcon as FolderMinus, FolderOpenIcon as FolderOpen, FolderPlusIcon as FolderPlus, FoldersIcon as Folders, SquaresFourIcon as LayoutGrid, CircleNotchIcon as Loader2, MagnifyingGlassIcon as Search, NotePencilIcon as NotebookPen, PencilIcon as Pencil, PlusIcon as Plus, NoteIcon as StickyNote, TableIcon as Table2, TrashIcon as Trash, XIcon as X, type Icon } from '@phosphor-icons/react';
27
+ import * as PhosphorIcons from '@phosphor-icons/react';
28
+
29
+ import { Button } from '@/components/ui/button';
30
+ import { cn } from '@/lib/utils';
31
+
32
+ type TabKey = 'gallery' | 'table' | 'calendar';
33
+
34
+ type TabConfig = {
35
+ key: TabKey;
36
+ label: string;
37
+ icon: Icon;
38
+ };
39
+
40
+ type FolderOption = {
41
+ id: string;
42
+ title: string;
43
+ parentId: string | null;
44
+ pathLabel: string;
45
+ };
46
+
47
+ type CollectionItemContext = {
48
+ id: string;
49
+ title: string;
50
+ icon?: string | null;
51
+ properties: Record<string, unknown>;
52
+ };
53
+
54
+ const NOTE_DATABASE_SLUG = 'notes';
55
+ const FOLDER_DATABASE_SLUG = 'note_folders';
56
+ const DEFAULT_NOTE_TITLE = 'Untitled note';
57
+ const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
58
+
59
+ // Sentinel for the built-in "Created" date, sourced from each document's
60
+ // created_at timestamp rather than a user-defined date property.
61
+ const CREATED_DATE_FIELD = '__created__';
62
+ const CREATED_DATE_LABEL = 'Created';
63
+
64
+ type CalendarDateField = {
65
+ value: string;
66
+ label: string;
67
+ };
68
+
69
+ const TABS: TabConfig[] = [
70
+ { key: 'gallery', label: 'Gallery', icon: LayoutGrid },
71
+ { key: 'table', label: 'Table', icon: Table2 },
72
+ { key: 'calendar', label: 'Calendar', icon: CalendarDays },
73
+ ];
74
+
75
+ type IconName = string;
76
+
77
+ const PHOSPHOR_ICON_PREFIX = 'phosphor:';
78
+
79
+ const PHOSPHOR_ICON_OVERRIDES: Record<string, string> = {
80
+ boxes: 'Cube',
81
+ 'calendar-days': 'Calendar',
82
+ 'layout-grid': 'SquaresFour',
83
+ 'layout-dashboard': 'SquaresFour',
84
+ 'link-2': 'Link',
85
+ 'notebook-pen': 'NotePencil',
86
+ 'rows-3': 'Rows',
87
+ search: 'MagnifyingGlass',
88
+ 'settings-2': 'SlidersHorizontal',
89
+ sparkles: 'Sparkle',
90
+ 'sticky-note': 'Note',
91
+ 'table-2': 'Table',
92
+ home: 'House',
93
+ 'folder-kanban': 'Kanban',
94
+ };
95
+
96
+ const SUGGESTED_FOLDER_ICON_NAMES: IconName[] = [
97
+ 'folder',
98
+ 'folder-open',
99
+ 'folder-kanban',
100
+ 'archive',
101
+ 'bookmark',
102
+ 'book-open',
103
+ 'briefcase',
104
+ 'calendar',
105
+ 'file-text',
106
+ 'flag',
107
+ 'house',
108
+ 'lightbulb',
109
+ 'note-pencil',
110
+ 'palette',
111
+ 'rocket',
112
+ 'magnifying-glass',
113
+ 'sparkle',
114
+ 'tag',
115
+ ];
116
+
117
+ function toPascalCase(value: string): string {
118
+ return value
119
+ .split(/[-_\s]+/)
120
+ .filter(Boolean)
121
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
122
+ .join('');
123
+ }
124
+
125
+ function toKebabCase(value: string): string {
126
+ return value
127
+ .replace(/Icon$/, '')
128
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
129
+ .toLowerCase();
130
+ }
131
+
132
+ function parsePhosphorIconName(icon: string | null | undefined): IconName | null {
133
+ if (typeof icon !== 'string' || !icon.startsWith(PHOSPHOR_ICON_PREFIX)) {
134
+ return null;
135
+ }
136
+ const name = icon.slice(PHOSPHOR_ICON_PREFIX.length).trim().replace(/_/g, '-').toLowerCase();
137
+ return name && /^[a-z][a-z0-9-]*$/.test(name) ? name : null;
138
+ }
139
+
140
+ function getPhosphorIconComponent(icon: string | null | undefined): Icon | null {
141
+ const name = parsePhosphorIconName(icon);
142
+ if (!name) return null;
143
+ const exportBaseName = PHOSPHOR_ICON_OVERRIDES[name] ?? toPascalCase(name);
144
+ const component = (PhosphorIcons as Record<string, unknown>)[`${exportBaseName}Icon`];
145
+ return typeof component === 'function' ? (component as Icon) : null;
146
+ }
147
+
148
+ function getPhosphorIconComponentByName(name: string): Icon | null {
149
+ return getPhosphorIconComponent(`${PHOSPHOR_ICON_PREFIX}${name}`);
150
+ }
151
+
152
+ const ALL_PHOSPHOR_ICON_NAMES = Object.keys(PhosphorIcons)
153
+ .filter((key) => key.endsWith('Icon') && key !== 'Icon')
154
+ .map(toKebabCase)
155
+ .sort((a, b) => a.localeCompare(b));
156
+
157
+ function normalizeCollectionItem(value: unknown): CollectionItemContext | null {
158
+ const record = asRecord(value);
159
+ const id = optionalString(record?.id);
160
+ if (!record || !id) return null;
161
+ return {
162
+ id,
163
+ title: optionalString(record.title) ?? 'Untitled',
164
+ icon: optionalString(record.icon),
165
+ properties: asRecord(record.properties) ?? {},
166
+ };
167
+ }
168
+
169
+ function pluralize(count: number, singular: string, plural = `${singular}s`): string {
170
+ return `${count} ${count === 1 ? singular : plural}`;
171
+ }
172
+
173
+ function getDateKey(value: unknown): string | null {
174
+ if (!isPresentString(value)) return null;
175
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})/);
176
+ return match ? match[1] : null;
177
+ }
178
+
179
+ function getDateInputValue(value: unknown): string {
180
+ return getDateKey(value) ?? '';
181
+ }
182
+
183
+ function getNoteDateValue(note: DocumentRecord, field: string): unknown {
184
+ if (field === CREATED_DATE_FIELD) return note.createdAt;
185
+ return note.properties[field];
186
+ }
187
+
188
+ function getFolderTitle(document: DocumentRecord): string {
189
+ const explicit = document.properties.Name;
190
+ if (isPresentString(explicit)) return explicit;
191
+ return document.title || 'Untitled folder';
192
+ }
193
+
194
+ function getNoteTitle(document: DocumentRecord): string {
195
+ return isPresentString(document.title) ? document.title : DEFAULT_NOTE_TITLE;
196
+ }
197
+
198
+ function getNotePreviewText(document: DocumentRecord): string {
199
+ return getDocumentPreview(document);
200
+ }
201
+
202
+ function buildFolderOptions(documents: DocumentRecord[]): FolderOption[] {
203
+ const byId = new Map(documents.map((d) => [d.id, d]));
204
+
205
+ function buildPath(id: string, seen: Set<string> = new Set()): string {
206
+ const folder = byId.get(id);
207
+ if (!folder) return 'Untitled folder';
208
+ const title = getFolderTitle(folder);
209
+ if (seen.has(id)) return title;
210
+ seen.add(id);
211
+ const parentId = getRelationIds(folder.properties.Parent)[0] ?? null;
212
+ if (!parentId || !byId.has(parentId)) return title;
213
+ return `${buildPath(parentId, seen)} / ${title}`;
214
+ }
215
+
216
+ return documents
217
+ .map((d) => ({
218
+ id: d.id,
219
+ title: getFolderTitle(d),
220
+ parentId: getRelationIds(d.properties.Parent)[0] ?? null,
221
+ pathLabel: buildPath(d.id),
222
+ }))
223
+ .sort((a, b) => a.pathLabel.localeCompare(b.pathLabel));
224
+ }
225
+
226
+ function getCoverUrl(document: DocumentRecord): string | null {
227
+ if (isPresentString(document.cover)) {
228
+ return document.cover.trim();
229
+ }
230
+ return null;
231
+ }
232
+
233
+ function NoteIcon({
234
+ icon,
235
+ className,
236
+ fallbackClassName,
237
+ }: {
238
+ icon: string | null | undefined;
239
+ className?: string;
240
+ fallbackClassName?: string;
241
+ }) {
242
+ const Icon = getPhosphorIconComponent(icon);
243
+ if (Icon) {
244
+ return <Icon className={cn('h-3.5 w-3.5', className)} />;
245
+ }
246
+ if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
247
+ return (
248
+ <img
249
+ src={icon.trim()}
250
+ alt=""
251
+ className={cn('h-3.5 w-3.5 rounded-sm object-cover', className)}
252
+ />
253
+ );
254
+ }
255
+ return <FileText className={cn('h-3.5 w-3.5', className, fallbackClassName)} />;
256
+ }
257
+
258
+ function formatDateLabel(value: unknown): string {
259
+ const key = getDateKey(value);
260
+ if (!key) return 'No date';
261
+ const date = new Date(`${key}T12:00:00`);
262
+ return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date);
263
+ }
264
+
265
+ function formatPropertyValue(
266
+ value: unknown,
267
+ property: DatabaseProperty | null,
268
+ folderNameById: Map<string, string>,
269
+ ): string {
270
+ if (property?.type === 'relation') {
271
+ const ids = getRelationIds(value);
272
+ if (!ids.length) return 'None';
273
+ return ids.map((id) => folderNameById.get(id) ?? id).join(', ');
274
+ }
275
+ if (property?.type === 'date') return formatDateLabel(value);
276
+ if (Array.isArray(value)) return value.length ? value.map((v) => String(v)).join(', ') : 'None';
277
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
278
+ if (typeof value === 'number') return String(value);
279
+ if (isPresentString(value)) return value;
280
+ return 'None';
281
+ }
282
+
283
+ function formatMonthLabel(month: Date): string {
284
+ return new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(month);
285
+ }
286
+
287
+ function startOfCalendarGrid(month: Date): Date {
288
+ const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
289
+ const mondayIndex = (first.getDay() + 6) % 7;
290
+ const start = new Date(first);
291
+ start.setDate(first.getDate() - mondayIndex);
292
+ return start;
293
+ }
294
+
295
+ function buildCalendarDays(month: Date): Date[] {
296
+ const start = startOfCalendarGrid(month);
297
+ return Array.from({ length: 42 }, (_, i) => {
298
+ const d = new Date(start);
299
+ d.setDate(start.getDate() + i);
300
+ return d;
301
+ });
302
+ }
303
+
304
+ function isSameMonth(a: Date, b: Date): boolean {
305
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
306
+ }
307
+
308
+ function isToday(day: Date): boolean {
309
+ const t = new Date();
310
+ return day.getFullYear() === t.getFullYear() && day.getMonth() === t.getMonth() && day.getDate() === t.getDate();
311
+ }
312
+
313
+ function getStatusLabel(value: unknown): string | null {
314
+ return isPresentString(value) ? value : null;
315
+ }
316
+
317
+ type StatusTone = 'active' | 'review' | 'done' | 'blocked' | 'idea' | 'neutral';
318
+
319
+ function getStatusTone(status: string | null): StatusTone {
320
+ if (!status) return 'neutral';
321
+ const s = status.toLowerCase();
322
+ if (s.includes('done') || s.includes('complete') || s.includes('ship')) return 'done';
323
+ if (s.includes('progress') || s.includes('active') || s.includes('draft')) return 'active';
324
+ if (s.includes('review') || s.includes('wait') || s.includes('hold')) return 'review';
325
+ if (s.includes('block') || s.includes('stuck')) return 'blocked';
326
+ if (s.includes('idea') || s.includes('backlog')) return 'idea';
327
+ return 'neutral';
328
+ }
329
+
330
+ const statusPillClasses: Record<StatusTone, string> = {
331
+ active: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
332
+ review: 'bg-blue-500/10 text-blue-700 dark:text-blue-400',
333
+ done: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
334
+ blocked: 'bg-red-500/10 text-red-700 dark:text-red-400',
335
+ idea: 'bg-muted text-muted-foreground',
336
+ neutral: 'bg-muted text-muted-foreground',
337
+ };
338
+
339
+ const statusDotClasses: Record<StatusTone, string> = {
340
+ active: 'bg-amber-500',
341
+ review: 'bg-blue-500',
342
+ done: 'bg-emerald-500',
343
+ blocked: 'bg-red-500',
344
+ idea: 'bg-stone-400',
345
+ neutral: 'bg-stone-400',
346
+ };
347
+
348
+ const statusBarClasses: Record<StatusTone, string> = {
349
+ active: 'border-l-amber-500',
350
+ review: 'border-l-blue-500',
351
+ done: 'border-l-emerald-500',
352
+ blocked: 'border-l-red-500',
353
+ idea: 'border-l-stone-400',
354
+ neutral: 'border-l-stone-400',
355
+ };
356
+
357
+ function StatusPill({ status }: { status: string | null }) {
358
+ if (!status) return null;
359
+ const tone = getStatusTone(status);
360
+ return (
361
+ <span
362
+ className={cn(
363
+ 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-tight',
364
+ statusPillClasses[tone],
365
+ )}
366
+ >
367
+ <span className={cn('h-1.5 w-1.5 rounded-full', statusDotClasses[tone])} />
368
+ {status}
369
+ </span>
370
+ );
371
+ }
372
+
373
+ function Eyebrow({ children, className }: { children: React.ReactNode; className?: string }) {
374
+ return (
375
+ <span
376
+ className={cn(
377
+ 'font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground',
378
+ className,
379
+ )}
380
+ >
381
+ {children}
382
+ </span>
383
+ );
384
+ }
385
+
386
+ function EmptyState({
387
+ icon: Icon,
388
+ title,
389
+ description,
390
+ action,
391
+ }: {
392
+ icon: Icon;
393
+ title: string;
394
+ description: string;
395
+ action?: React.ReactNode;
396
+ }) {
397
+ return (
398
+ <div className="flex flex-col items-center justify-center px-6 py-16 text-center">
399
+ <Icon className="mb-4 h-10 w-10 stroke-[1.5] text-muted-foreground/30" />
400
+ <p className="text-sm font-semibold text-foreground">{title}</p>
401
+ <p className="mt-1 max-w-sm text-[13px] text-muted-foreground">{description}</p>
402
+ {action ? <div className="mt-5">{action}</div> : null}
403
+ </div>
404
+ );
405
+ }
406
+
407
+ function LoadingState() {
408
+ return (
409
+ <div className="flex min-h-[360px] items-center justify-center gap-2 text-sm text-muted-foreground">
410
+ <Loader2 className="h-4 w-4 animate-spin" />
411
+ Loading notes…
412
+ </div>
413
+ );
414
+ }
415
+
416
+ function PageIcon({ icon }: { icon: string | null | undefined }) {
417
+ const Icon = getPhosphorIconComponent(icon);
418
+ if (Icon) {
419
+ return <Icon className="h-7 w-7 stroke-[1.5] text-foreground" />;
420
+ }
421
+ if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
422
+ return (
423
+ <img
424
+ src={icon.trim()}
425
+ alt=""
426
+ className="h-7 w-7 rounded-md object-cover"
427
+ />
428
+ );
429
+ }
430
+ return <Boxes className="h-7 w-7 stroke-[1.5] text-foreground" />;
431
+ }
432
+
433
+ function FolderIconPicker({
434
+ displayIcon,
435
+ storedIcon,
436
+ disabled,
437
+ onChange,
438
+ onRemove,
439
+ }: {
440
+ displayIcon: string | null;
441
+ storedIcon: string | null;
442
+ disabled?: boolean;
443
+ onChange: (icon: string) => void;
444
+ onRemove: () => void;
445
+ }) {
446
+ const [open, setOpen] = useState(false);
447
+ const [query, setQuery] = useState('');
448
+ const containerRef = useRef<HTMLDivElement>(null);
449
+ const selectedName = parsePhosphorIconName(storedIcon);
450
+ const filteredIcons = useMemo(() => {
451
+ const normalizedQuery = query.trim().toLowerCase();
452
+ if (!normalizedQuery) return SUGGESTED_FOLDER_ICON_NAMES;
453
+ return ALL_PHOSPHOR_ICON_NAMES
454
+ .filter((name) => name.replace(/-/g, ' ').includes(normalizedQuery))
455
+ .slice(0, 96);
456
+ }, [query]);
457
+
458
+ useEffect(() => {
459
+ if (!open) return;
460
+
461
+ function handlePointerDown(event: Event) {
462
+ const target = event.target;
463
+ if (target instanceof Node && containerRef.current?.contains(target)) {
464
+ return;
465
+ }
466
+ setOpen(false);
467
+ }
468
+
469
+ const root = containerRef.current?.getRootNode();
470
+ const eventTarget =
471
+ root instanceof ShadowRoot || root instanceof Document
472
+ ? root
473
+ : document;
474
+ eventTarget.addEventListener('pointerdown', handlePointerDown);
475
+ return () => eventTarget.removeEventListener('pointerdown', handlePointerDown);
476
+ }, [open]);
477
+
478
+ return (
479
+ <div ref={containerRef} className="relative w-fit">
480
+ <button
481
+ type="button"
482
+ aria-label="Edit folder icon"
483
+ disabled={disabled}
484
+ onClick={() => setOpen((value) => !value)}
485
+ 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"
486
+ >
487
+ <PageIcon icon={displayIcon} />
488
+ </button>
489
+ {open ? (
490
+ <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">
491
+ <div className="mb-2 flex items-center gap-2">
492
+ <input
493
+ autoFocus
494
+ value={query}
495
+ onChange={(event) => setQuery(event.target.value)}
496
+ placeholder="Search icons"
497
+ 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"
498
+ />
499
+ <button
500
+ type="button"
501
+ aria-label="Close icon picker"
502
+ onClick={() => setOpen(false)}
503
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
504
+ >
505
+ <X className="h-3.5 w-3.5" />
506
+ </button>
507
+ </div>
508
+ <div className="grid max-h-[220px] grid-cols-8 gap-1 overflow-y-auto pr-1">
509
+ {filteredIcons.map((name) => {
510
+ const Icon = getPhosphorIconComponentByName(name);
511
+ if (!Icon) return null;
512
+
513
+ return (
514
+ <button
515
+ key={name}
516
+ type="button"
517
+ title={name}
518
+ aria-label={`Use ${name} icon`}
519
+ onPointerDown={(event) => {
520
+ event.preventDefault();
521
+ event.stopPropagation();
522
+ onChange(`${PHOSPHOR_ICON_PREFIX}${name}`);
523
+ setOpen(false);
524
+ }}
525
+ className={cn(
526
+ 'inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
527
+ selectedName === name && 'bg-muted text-foreground ring-1 ring-border',
528
+ )}
529
+ >
530
+ <Icon className="h-4 w-4" />
531
+ </button>
532
+ );
533
+ })}
534
+ </div>
535
+ {filteredIcons.length === 0 ? (
536
+ <p className="py-6 text-center text-sm text-muted-foreground">No icons found.</p>
537
+ ) : null}
538
+ <div className="mt-3 border-t border-border pt-2">
539
+ <button
540
+ type="button"
541
+ disabled={!storedIcon}
542
+ onPointerDown={(event) => {
543
+ event.preventDefault();
544
+ event.stopPropagation();
545
+ if (!storedIcon) return;
546
+ onRemove();
547
+ setOpen(false);
548
+ }}
549
+ 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"
550
+ >
551
+ Remove custom icon
552
+ </button>
553
+ </div>
554
+ </div>
555
+ ) : null}
556
+ </div>
557
+ );
558
+ }
559
+
560
+ function ViewPill({
561
+ config,
562
+ active,
563
+ onSelect,
564
+ }: {
565
+ config: TabConfig;
566
+ active: boolean;
567
+ onSelect: () => void;
568
+ }) {
569
+ const Icon = config.icon;
570
+ return (
571
+ <button
572
+ type="button"
573
+ onClick={onSelect}
574
+ className={cn(
575
+ 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[12.5px] font-medium transition-colors',
576
+ active
577
+ ? 'bg-muted text-foreground'
578
+ : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
579
+ )}
580
+ >
581
+ <Icon className="h-3.5 w-3.5" />
582
+ {config.label}
583
+ </button>
584
+ );
585
+ }
586
+
587
+ function PageHeader({
588
+ title,
589
+ icon,
590
+ storedIcon = null,
591
+ iconEditable = false,
592
+ iconSaving = false,
593
+ titleEditable = false,
594
+ titleEditing = false,
595
+ titleDraft = '',
596
+ titleSaving = false,
597
+ activeTab,
598
+ onTabChange,
599
+ onIconChange,
600
+ onIconRemove,
601
+ onStartTitleEdit,
602
+ onTitleDraftChange,
603
+ onSubmitTitleEdit,
604
+ onCancelTitleEdit,
605
+ onCreateNote,
606
+ creatingNote = false,
607
+ }: {
608
+ title: string;
609
+ icon: string | null;
610
+ storedIcon?: string | null;
611
+ iconEditable?: boolean;
612
+ iconSaving?: boolean;
613
+ titleEditable?: boolean;
614
+ titleEditing?: boolean;
615
+ titleDraft?: string;
616
+ titleSaving?: boolean;
617
+ activeTab: TabKey;
618
+ onTabChange: (tab: TabKey) => void;
619
+ onIconChange?: (icon: string) => void;
620
+ onIconRemove?: () => void;
621
+ onStartTitleEdit?: () => void;
622
+ onTitleDraftChange?: (title: string) => void;
623
+ onSubmitTitleEdit?: () => void;
624
+ onCancelTitleEdit?: () => void;
625
+ onCreateNote?: () => void;
626
+ creatingNote?: boolean;
627
+ }) {
628
+ return (
629
+ <div className="flex flex-col gap-3 px-6 pt-6">
630
+ {iconEditable ? (
631
+ <FolderIconPicker
632
+ displayIcon={icon}
633
+ storedIcon={storedIcon}
634
+ disabled={iconSaving}
635
+ onChange={(nextIcon) => onIconChange?.(nextIcon)}
636
+ onRemove={() => onIconRemove?.()}
637
+ />
638
+ ) : (
639
+ <div className="flex size-10 items-center justify-center">
640
+ <PageIcon icon={icon} />
641
+ </div>
642
+ )}
643
+ {titleEditing ? (
644
+ <input
645
+ autoFocus
646
+ value={titleDraft}
647
+ onChange={(event) => onTitleDraftChange?.(event.target.value)}
648
+ onKeyDown={(event) => {
649
+ if (event.key === 'Enter') {
650
+ event.preventDefault();
651
+ onSubmitTitleEdit?.();
652
+ }
653
+ if (event.key === 'Escape') {
654
+ event.preventDefault();
655
+ onCancelTitleEdit?.();
656
+ }
657
+ }}
658
+ onBlur={() => onSubmitTitleEdit?.()}
659
+ 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"
660
+ />
661
+ ) : (
662
+ <div className="flex min-w-0 items-center gap-2">
663
+ <h1
664
+ className={cn(
665
+ 'min-w-0 truncate text-3xl font-bold tracking-tight text-foreground',
666
+ titleEditable && 'cursor-text',
667
+ )}
668
+ onDoubleClick={titleEditable ? onStartTitleEdit : undefined}
669
+ >
670
+ {title}
671
+ </h1>
672
+ {titleEditable ? (
673
+ <button
674
+ type="button"
675
+ aria-label="Rename folder"
676
+ onClick={onStartTitleEdit}
677
+ disabled={titleSaving}
678
+ 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"
679
+ >
680
+ {titleSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
681
+ </button>
682
+ ) : null}
683
+ </div>
684
+ )}
685
+ <div className="flex flex-wrap items-center gap-2 border-b border-border pb-2">
686
+ <div className="flex items-center gap-1">
687
+ {TABS.map((tab) => (
688
+ <ViewPill
689
+ key={tab.key}
690
+ config={tab}
691
+ active={activeTab === tab.key}
692
+ onSelect={() => onTabChange(tab.key)}
693
+ />
694
+ ))}
695
+ </div>
696
+ <div className="ml-auto flex items-center gap-0.5">
697
+ <button
698
+ type="button"
699
+ onClick={onCreateNote}
700
+ disabled={!onCreateNote || creatingNote}
701
+ className="ml-1 inline-flex h-7 items-center gap-1 rounded-md bg-primary px-2.5 text-[12.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-60"
702
+ >
703
+ {creatingNote ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
704
+ {creatingNote ? 'Creating…' : 'New'}
705
+ </button>
706
+ </div>
707
+ </div>
708
+ </div>
709
+ );
710
+ }
711
+
712
+ export default function NotesPage() {
713
+ const { app, route, collectionItem: rawCollectionItem } = useNotis();
714
+ const navigation = useNotisNavigation();
715
+ const { request } = useBackend();
716
+ const { upsert: upsertNoteDocument } = useUpsertDocument(NOTE_DATABASE_SLUG);
717
+
718
+ const [activeTab, setActiveTab] = useState<TabKey>('gallery');
719
+ const [activeDateProperty, setActiveDateProperty] = useState(CREATED_DATE_FIELD);
720
+ const [visibleMonth, setVisibleMonth] = useState(() => new Date());
721
+ const [creatingDocument, setCreatingDocument] = useState(false);
722
+ const [savingNoteId, setSavingNoteId] = useState<string | null>(null);
723
+ const [savingFolderTitle, setSavingFolderTitle] = useState(false);
724
+ const [savingFolderIcon, setSavingFolderIcon] = useState(false);
725
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
726
+ const [searchQuery, setSearchQuery] = useState('');
727
+ const [folderTitleDraft, setFolderTitleDraft] = useState('');
728
+ const [editingFolderTitle, setEditingFolderTitle] = useState(false);
729
+ const [folderTitleOverride, setFolderTitleOverride] = useState<{ id: string; title: string } | null>(null);
730
+ const [folderIconOverride, setFolderIconOverride] = useState<{ id: string; icon: string | null } | null>(null);
731
+ // Which bulk folder action is awaiting a target-folder pick, if any.
732
+ const [pendingFolderAction, setPendingFolderAction] = useState<'move' | 'add' | null>(null);
733
+ const [bulkRunning, setBulkRunning] = useState(false);
734
+ const folderRenameSubmittingRef = useRef(false);
735
+
736
+ const collectionItem = normalizeCollectionItem(rawCollectionItem);
737
+ const noteSchema = useDatabaseSchema(NOTE_DATABASE_SLUG);
738
+ const noteProperties = noteSchema.properties;
739
+ const titleProperty = noteProperties.find((p) => p.type === 'title');
740
+ const titlePropertyName = titleProperty?.name ?? 'Title';
741
+ const folderPropertyName = noteProperties.find((p) => p.type === 'relation')?.name ?? 'Folder';
742
+ const statusPropertyName =
743
+ noteProperties.find((p) => p.type === 'status' || p.name === 'Status')?.name ?? null;
744
+ const dateProperties = noteProperties.filter((p) => p.type === 'date');
745
+ const metadataProperties = noteProperties.filter((p) => p.name !== titlePropertyName);
746
+
747
+ const activeFolderId = collectionItem?.id ?? null;
748
+ const activeFolderTitle =
749
+ activeFolderId && folderTitleOverride?.id === activeFolderId
750
+ ? folderTitleOverride.title
751
+ : collectionItem?.title ?? null;
752
+ const activeFolderIcon =
753
+ activeFolderId && folderIconOverride?.id === activeFolderId
754
+ ? folderIconOverride.icon
755
+ : collectionItem?.icon ?? null;
756
+
757
+ const notesFilter = activeFolderId
758
+ ? {
759
+ filters: [
760
+ {
761
+ property: folderPropertyName,
762
+ operator: 'contains',
763
+ type: 'relation',
764
+ value: activeFolderId,
765
+ },
766
+ ],
767
+ }
768
+ : undefined;
769
+
770
+ const notesQuery = useDocuments(NOTE_DATABASE_SLUG, { filter: notesFilter, pageSize: 250 });
771
+ const foldersQuery = useDocuments(FOLDER_DATABASE_SLUG, { pageSize: 500 });
772
+
773
+ const { setLoading: setSearchLoading } = useTopBarSearch({
774
+ value: searchQuery,
775
+ onChange: setSearchQuery,
776
+ placeholder: 'Search notes…',
777
+ onSubmit: notesQuery.refetch,
778
+ });
779
+
780
+ useEffect(() => {
781
+ setSearchLoading(notesQuery.loading);
782
+ }, [notesQuery.loading, setSearchLoading]);
783
+
784
+ const trimmedQuery = searchQuery.trim().toLowerCase();
785
+ const allNotes = notesQuery.documents;
786
+ const notes = trimmedQuery
787
+ ? allNotes.filter((note) => {
788
+ if (getNoteTitle(note).toLowerCase().includes(trimmedQuery)) return true;
789
+ if (isPresentString(note.plainText) && note.plainText.toLowerCase().includes(trimmedQuery)) return true;
790
+ if (isPresentString(note.contentMarkdown) && note.contentMarkdown.toLowerCase().includes(trimmedQuery)) return true;
791
+ return false;
792
+ })
793
+ : allNotes;
794
+ const folders = foldersQuery.documents;
795
+ const folderOptions = buildFolderOptions(folders);
796
+ const folderNameById = new Map(folderOptions.map((f) => [f.id, f.title]));
797
+
798
+ // The calendar always offers a built-in "Created" field (sourced from each
799
+ // note's created_at) plus any user-defined date properties on the database.
800
+ const calendarDateFields: CalendarDateField[] = [
801
+ { value: CREATED_DATE_FIELD, label: CREATED_DATE_LABEL },
802
+ ...dateProperties.map((p) => ({ value: p.name, label: p.name })),
803
+ ];
804
+ const activeCalendarField = calendarDateFields.some((f) => f.value === activeDateProperty)
805
+ ? activeDateProperty
806
+ : CREATED_DATE_FIELD;
807
+
808
+ const notesByDay = new Map<string, DocumentRecord[]>();
809
+ for (const note of notes) {
810
+ const key = getDateKey(getNoteDateValue(note, activeCalendarField));
811
+ if (!key) continue;
812
+ const bucket = notesByDay.get(key) ?? [];
813
+ bucket.push(note);
814
+ notesByDay.set(key, bucket);
815
+ }
816
+ const scheduledNotesCount = notes.filter((n) =>
817
+ Boolean(getDateKey(getNoteDateValue(n, activeCalendarField))),
818
+ ).length;
819
+
820
+ const monthDays = buildCalendarDays(visibleMonth);
821
+ const isLoading = notesQuery.loading || foldersQuery.loading || noteSchema.loading;
822
+ const currentFolderLabel = activeFolderTitle ?? 'All notes';
823
+ const topLevelError = errorMessage || notesQuery.error?.message || foldersQuery.error?.message || noteSchema.error?.message;
824
+ const pageTitle = activeFolderTitle || route?.name || app?.name || 'Notes';
825
+ const pageIcon = activeFolderIcon || route?.icon || null;
826
+
827
+ useEffect(() => {
828
+ setEditingFolderTitle(false);
829
+ setFolderTitleDraft('');
830
+ folderRenameSubmittingRef.current = false;
831
+ setFolderIconOverride(null);
832
+ }, [activeFolderId]);
833
+
834
+ useEffect(() => {
835
+ const valid =
836
+ activeDateProperty === CREATED_DATE_FIELD ||
837
+ dateProperties.some((p) => p.name === activeDateProperty);
838
+ if (!valid) setActiveDateProperty(CREATED_DATE_FIELD);
839
+ }, [activeDateProperty, dateProperties]);
840
+
841
+ async function openNote(document: DocumentRecord) {
842
+ navigation.toDocument(document.id, document.title);
843
+ }
844
+
845
+ async function createDocument() {
846
+ setCreatingDocument(true);
847
+ setErrorMessage(null);
848
+ try {
849
+ const document = await upsertNoteDocument({
850
+ title: DEFAULT_NOTE_TITLE,
851
+ properties: activeFolderId ? { [folderPropertyName]: [activeFolderId] } : undefined,
852
+ });
853
+ navigation.toDocument(document.id, document.title);
854
+ } catch (error) {
855
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to create note');
856
+ } finally {
857
+ setCreatingDocument(false);
858
+ }
859
+ }
860
+
861
+ async function saveProperties(documentId: string, properties: Record<string, unknown>) {
862
+ setSavingNoteId(documentId);
863
+ setErrorMessage(null);
864
+ try {
865
+ await upsertNoteDocument({ documentId, properties });
866
+ notesQuery.refetch();
867
+ } catch (error) {
868
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to save note');
869
+ } finally {
870
+ setSavingNoteId(null);
871
+ }
872
+ }
873
+
874
+ async function saveTitle(documentId: string, nextTitle: string) {
875
+ const normalized = nextTitle.trim() || DEFAULT_NOTE_TITLE;
876
+ setSavingNoteId(documentId);
877
+ setErrorMessage(null);
878
+ try {
879
+ await upsertNoteDocument({ documentId, title: normalized });
880
+ notesQuery.refetch();
881
+ } catch (error) {
882
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to rename note');
883
+ } finally {
884
+ setSavingNoteId(null);
885
+ }
886
+ }
887
+
888
+ function startFolderTitleEdit() {
889
+ if (!activeFolderId) return;
890
+ setFolderTitleDraft(activeFolderTitle ?? '');
891
+ setEditingFolderTitle(true);
892
+ }
893
+
894
+ function cancelFolderTitleEdit() {
895
+ setEditingFolderTitle(false);
896
+ setFolderTitleDraft('');
897
+ folderRenameSubmittingRef.current = false;
898
+ }
899
+
900
+ async function submitFolderTitleEdit() {
901
+ if (folderRenameSubmittingRef.current) return;
902
+ if (!activeFolderId || !app?.id || !route?.slug) {
903
+ cancelFolderTitleEdit();
904
+ return;
905
+ }
906
+
907
+ const previousTitle = activeFolderTitle ?? collectionItem?.title ?? '';
908
+ const nextTitle = folderTitleDraft.trim();
909
+ if (!nextTitle || nextTitle === previousTitle) {
910
+ cancelFolderTitleEdit();
911
+ return;
912
+ }
913
+
914
+ folderRenameSubmittingRef.current = true;
915
+ setEditingFolderTitle(false);
916
+ setFolderTitleOverride({ id: activeFolderId, title: nextTitle });
917
+ setSavingFolderTitle(true);
918
+ setErrorMessage(null);
919
+ try {
920
+ const response = await request('/portal_views/collection_tree/rename', {
921
+ method: 'POST',
922
+ body: {
923
+ app_id: app.id,
924
+ route_slug: route.slug,
925
+ item_id: activeFolderId,
926
+ title: nextTitle,
927
+ },
928
+ }) as { item?: { title?: unknown } };
929
+ const savedTitle = typeof response.item?.title === 'string' && response.item.title.trim()
930
+ ? response.item.title
931
+ : nextTitle;
932
+ setFolderTitleOverride({ id: activeFolderId, title: savedTitle });
933
+ foldersQuery.refetch();
934
+ } catch (error) {
935
+ setFolderTitleOverride(previousTitle ? { id: activeFolderId, title: previousTitle } : null);
936
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to rename folder');
937
+ } finally {
938
+ setSavingFolderTitle(false);
939
+ setFolderTitleDraft('');
940
+ folderRenameSubmittingRef.current = false;
941
+ }
942
+ }
943
+
944
+ async function saveFolderIcon(nextIcon: string | null) {
945
+ if (!activeFolderId || !app?.id || !route?.slug) return;
946
+ if (nextIcon === activeFolderIcon) return;
947
+
948
+ const previousIcon = activeFolderIcon;
949
+ setFolderIconOverride({ id: activeFolderId, icon: nextIcon });
950
+ setSavingFolderIcon(true);
951
+ setErrorMessage(null);
952
+ try {
953
+ const response = await request('/portal_views/collection_tree/icon', {
954
+ method: 'POST',
955
+ body: {
956
+ app_id: app.id,
957
+ route_slug: route.slug,
958
+ item_id: activeFolderId,
959
+ icon: nextIcon,
960
+ },
961
+ }) as { item?: { icon?: unknown } };
962
+ const savedIcon = typeof response.item?.icon === 'string' ? response.item.icon : null;
963
+ setFolderIconOverride({ id: activeFolderId, icon: savedIcon });
964
+ foldersQuery.refetch();
965
+ } catch (error) {
966
+ setFolderIconOverride({ id: activeFolderId, icon: previousIcon });
967
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to update folder icon');
968
+ } finally {
969
+ setSavingFolderIcon(false);
970
+ }
971
+ }
972
+
973
+ // Holds both table rows and gallery cards so Shift+Arrow can scroll the head
974
+ // into view regardless of the active view.
975
+ const rowRefs = useRef(new Map<string, HTMLElement>());
976
+ const selectionEnabled = activeTab !== 'calendar';
977
+ const multiSelect = useMultiSelect<DocumentRecord>({
978
+ items: notes,
979
+ getId: (note) => note.id,
980
+ bindKeyboardShortcuts: selectionEnabled,
981
+ enableDragSelect: selectionEnabled,
982
+ onHeadChange: (id) => {
983
+ if (!id) return;
984
+ rowRefs.current.get(id)?.scrollIntoView({ block: 'nearest' });
985
+ },
986
+ });
987
+
988
+ // Clear the selection when the visible set changes out from under it (folder
989
+ // switch) or when entering a view that can't act on a selection (calendar),
990
+ // so bulk actions never apply to off-screen notes.
991
+ const clearSelection = multiSelect.clear;
992
+ useEffect(() => {
993
+ clearSelection();
994
+ }, [activeFolderId, clearSelection]);
995
+ useEffect(() => {
996
+ if (!selectionEnabled) clearSelection();
997
+ }, [selectionEnabled, clearSelection]);
998
+
999
+ // Runs `apply` for each selected note in parallel, then clears + refetches.
1000
+ // `apply` receives the full note so handlers can read its current properties.
1001
+ async function runBulk(
1002
+ apply: (note: DocumentRecord) => UpsertDocumentArgs,
1003
+ failureMessage: string,
1004
+ ) {
1005
+ const selected = multiSelect.getSelectedItems();
1006
+ if (selected.length === 0) return;
1007
+ setBulkRunning(true);
1008
+ setErrorMessage(null);
1009
+ try {
1010
+ await Promise.all(selected.map((note) => upsertNoteDocument(apply(note))));
1011
+ multiSelect.clear();
1012
+ notesQuery.refetch();
1013
+ } catch (error) {
1014
+ setErrorMessage(error instanceof Error ? error.message : failureMessage);
1015
+ } finally {
1016
+ setBulkRunning(false);
1017
+ }
1018
+ }
1019
+
1020
+ function bulkClearFolder() {
1021
+ return runBulk(
1022
+ (note) => ({ documentId: note.id, properties: { [folderPropertyName]: [] } }),
1023
+ 'Failed to update notes',
1024
+ );
1025
+ }
1026
+
1027
+ function bulkDelete() {
1028
+ return runBulk(
1029
+ (note) => ({ documentId: note.id, operation: 'archive' }),
1030
+ 'Failed to delete notes',
1031
+ );
1032
+ }
1033
+
1034
+ function bulkMoveToFolder(folderId: string) {
1035
+ return runBulk(
1036
+ (note) => ({ documentId: note.id, properties: { [folderPropertyName]: [folderId] } }),
1037
+ 'Failed to move notes',
1038
+ );
1039
+ }
1040
+
1041
+ function bulkAddToFolder(folderId: string) {
1042
+ return runBulk(
1043
+ (note) => ({
1044
+ documentId: note.id,
1045
+ properties: {
1046
+ [folderPropertyName]: Array.from(
1047
+ new Set([...getRelationIds(note.properties[folderPropertyName]), folderId]),
1048
+ ),
1049
+ },
1050
+ }),
1051
+ 'Failed to add notes to folder',
1052
+ );
1053
+ }
1054
+
1055
+ async function handleFolderPick(folderId: string) {
1056
+ const action = pendingFolderAction;
1057
+ setPendingFolderAction(null);
1058
+ if (!action) return;
1059
+ if (action === 'move') await bulkMoveToFolder(folderId);
1060
+ else await bulkAddToFolder(folderId);
1061
+ }
1062
+
1063
+ return (
1064
+ <main className="flex min-h-screen flex-col bg-background">
1065
+ <PageHeader
1066
+ title={pageTitle}
1067
+ icon={pageIcon}
1068
+ storedIcon={activeFolderIcon}
1069
+ iconEditable={Boolean(activeFolderId)}
1070
+ iconSaving={savingFolderIcon}
1071
+ onIconChange={(nextIcon) => void saveFolderIcon(nextIcon)}
1072
+ onIconRemove={() => void saveFolderIcon(null)}
1073
+ titleEditable={Boolean(activeFolderId)}
1074
+ titleEditing={editingFolderTitle}
1075
+ titleDraft={folderTitleDraft}
1076
+ titleSaving={savingFolderTitle}
1077
+ onStartTitleEdit={startFolderTitleEdit}
1078
+ onTitleDraftChange={setFolderTitleDraft}
1079
+ onSubmitTitleEdit={() => void submitFolderTitleEdit()}
1080
+ onCancelTitleEdit={cancelFolderTitleEdit}
1081
+ activeTab={activeTab}
1082
+ onTabChange={setActiveTab}
1083
+ onCreateNote={() => void createDocument()}
1084
+ creatingNote={creatingDocument}
1085
+ />
1086
+
1087
+ {topLevelError ? (
1088
+ <div className="mx-6 mt-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
1089
+ {topLevelError}
1090
+ </div>
1091
+ ) : null}
1092
+
1093
+ {isLoading ? <LoadingState /> : null}
1094
+
1095
+ {!isLoading && activeTab === 'gallery' ? (
1096
+ <GalleryBody
1097
+ notes={notes}
1098
+ statusPropertyName={statusPropertyName}
1099
+ onOpenNote={openNote}
1100
+ onCreateDocument={createDocument}
1101
+ creatingDocument={creatingDocument}
1102
+ currentFolderLabel={currentFolderLabel}
1103
+ hasCollectionItem={Boolean(activeFolderId)}
1104
+ multiSelect={multiSelect}
1105
+ rowRefs={rowRefs.current}
1106
+ />
1107
+ ) : null}
1108
+
1109
+ {!isLoading && activeTab === 'table' ? (
1110
+ <TableBody
1111
+ notes={notes}
1112
+ metadataProperties={metadataProperties}
1113
+ folderOptions={folderOptions}
1114
+ folderNameById={folderNameById}
1115
+ folderPropertyName={folderPropertyName}
1116
+ savingNoteId={savingNoteId}
1117
+ onOpen={openNote}
1118
+ onSaveTitle={saveTitle}
1119
+ onSaveProperties={saveProperties}
1120
+ currentFolderLabel={currentFolderLabel}
1121
+ hasCollectionItem={Boolean(activeFolderId)}
1122
+ multiSelect={multiSelect}
1123
+ rowRefs={rowRefs.current}
1124
+ />
1125
+ ) : null}
1126
+
1127
+ {!isLoading && activeTab === 'calendar' ? (
1128
+ <CalendarBody
1129
+ monthDays={monthDays}
1130
+ visibleMonth={visibleMonth}
1131
+ setVisibleMonth={setVisibleMonth}
1132
+ notesByDay={notesByDay}
1133
+ statusPropertyName={statusPropertyName}
1134
+ calendarDateFields={calendarDateFields}
1135
+ activeDateProperty={activeCalendarField}
1136
+ setActiveDateProperty={setActiveDateProperty}
1137
+ scheduledNotesCount={scheduledNotesCount}
1138
+ onOpen={openNote}
1139
+ />
1140
+ ) : null}
1141
+
1142
+ {selectionEnabled ? (
1143
+ <>
1144
+ <MultiSelectDragOverlay rect={multiSelect.dragRect} />
1145
+ {pendingFolderAction ? (
1146
+ <BulkFolderPicker
1147
+ mode={pendingFolderAction}
1148
+ folderOptions={folderOptions}
1149
+ busy={bulkRunning}
1150
+ onPick={(folderId) => void handleFolderPick(folderId)}
1151
+ onClose={() => setPendingFolderAction(null)}
1152
+ />
1153
+ ) : null}
1154
+ <MultiSelectActionBar
1155
+ selectedCount={multiSelect.selectedCount}
1156
+ itemLabel={{ singular: 'note', plural: 'notes' }}
1157
+ actions={[
1158
+ {
1159
+ id: 'move-folder',
1160
+ label: 'Move to folder',
1161
+ shortcut: 'M',
1162
+ icon: <FolderOpen className="h-3.5 w-3.5" />,
1163
+ onRun: () => setPendingFolderAction('move'),
1164
+ },
1165
+ {
1166
+ id: 'add-folder',
1167
+ label: 'Add to folder',
1168
+ shortcut: 'F',
1169
+ icon: <FolderPlus className="h-3.5 w-3.5" />,
1170
+ onRun: () => setPendingFolderAction('add'),
1171
+ },
1172
+ {
1173
+ id: 'clear-folder',
1174
+ label: 'Move out of folder',
1175
+ icon: <FolderMinus className="h-3.5 w-3.5" />,
1176
+ onRun: bulkClearFolder,
1177
+ },
1178
+ {
1179
+ id: 'delete',
1180
+ label: 'Delete',
1181
+ shortcut: '#',
1182
+ destructive: true,
1183
+ icon: <Trash className="h-3.5 w-3.5" />,
1184
+ onRun: bulkDelete,
1185
+ },
1186
+ ]}
1187
+ />
1188
+ </>
1189
+ ) : null}
1190
+ </main>
1191
+ );
1192
+ }
1193
+
1194
+ /* -------------------------------------------------------------------------- */
1195
+ /* Gallery view */
1196
+ /* -------------------------------------------------------------------------- */
1197
+
1198
+ function GalleryBody({
1199
+ notes,
1200
+ statusPropertyName,
1201
+ onOpenNote,
1202
+ onCreateDocument,
1203
+ creatingDocument,
1204
+ currentFolderLabel,
1205
+ hasCollectionItem,
1206
+ multiSelect,
1207
+ rowRefs,
1208
+ }: {
1209
+ notes: DocumentRecord[];
1210
+ statusPropertyName: string | null;
1211
+ onOpenNote: (doc: DocumentRecord) => void;
1212
+ onCreateDocument: () => Promise<void>;
1213
+ creatingDocument: boolean;
1214
+ currentFolderLabel: string;
1215
+ hasCollectionItem: boolean;
1216
+ multiSelect: MultiSelectController<DocumentRecord>;
1217
+ rowRefs: Map<string, HTMLElement>;
1218
+ }) {
1219
+ if (!notes.length) {
1220
+ return (
1221
+ <EmptyState
1222
+ icon={LayoutGrid}
1223
+ title={hasCollectionItem ? 'No notes in this folder' : 'No notes yet'}
1224
+ description={
1225
+ hasCollectionItem
1226
+ ? `Create the first note in ${currentFolderLabel}.`
1227
+ : 'Folders live in the sidebar. Create a note here to get started.'
1228
+ }
1229
+ action={
1230
+ <Button
1231
+ size="sm"
1232
+ onClick={() => void onCreateDocument()}
1233
+ disabled={creatingDocument}
1234
+ className="gap-1.5"
1235
+ >
1236
+ <Plus className="h-3.5 w-3.5" />
1237
+ {creatingDocument ? 'Creating…' : 'New note'}
1238
+ </Button>
1239
+ }
1240
+ />
1241
+ );
1242
+ }
1243
+
1244
+ return (
1245
+ <div
1246
+ {...multiSelect.getContainerProps()}
1247
+ className="grid grid-cols-1 gap-4 px-4 py-4 sm:grid-cols-2 sm:px-6 lg:grid-cols-3 xl:grid-cols-4"
1248
+ >
1249
+ {notes.map((note) => (
1250
+ <NoteCard
1251
+ key={note.id}
1252
+ note={note}
1253
+ statusPropertyName={statusPropertyName}
1254
+ onOpen={() => onOpenNote(note)}
1255
+ isSelected={multiSelect.isSelected(note.id)}
1256
+ itemProps={multiSelect.getItemProps(note.id)}
1257
+ checkboxProps={multiSelect.getCheckboxProps(note.id)}
1258
+ cardRef={(node) => {
1259
+ if (node) {
1260
+ rowRefs.set(note.id, node);
1261
+ } else {
1262
+ rowRefs.delete(note.id);
1263
+ }
1264
+ }}
1265
+ />
1266
+ ))}
1267
+ </div>
1268
+ );
1269
+ }
1270
+
1271
+ function NoteCard({
1272
+ note,
1273
+ statusPropertyName,
1274
+ onOpen,
1275
+ isSelected,
1276
+ itemProps,
1277
+ checkboxProps,
1278
+ cardRef,
1279
+ }: {
1280
+ note: DocumentRecord;
1281
+ statusPropertyName: string | null;
1282
+ onOpen: () => void;
1283
+ isSelected: boolean;
1284
+ itemProps: { 'data-notis-row-id': string; onMouseDown: (event: React.MouseEvent) => void };
1285
+ checkboxProps: { isSelected: boolean; onClick: (event: React.MouseEvent) => void };
1286
+ cardRef: (node: HTMLDivElement | null) => void;
1287
+ }) {
1288
+ const status = statusPropertyName ? getStatusLabel(note.properties[statusPropertyName]) : null;
1289
+ const coverUrl = getCoverUrl(note);
1290
+ const title = getNoteTitle(note);
1291
+ const previewText = getNotePreviewText(note);
1292
+
1293
+ // A plain div (no `role="button"`) so the SDK's drag-select can arm from a
1294
+ // card body; keyboard access is preserved via tabIndex + Enter.
1295
+ return (
1296
+ <div
1297
+ {...itemProps}
1298
+ ref={cardRef}
1299
+ tabIndex={0}
1300
+ onClick={onOpen}
1301
+ onKeyDown={(e) => {
1302
+ if (e.key === 'Enter') {
1303
+ e.preventDefault();
1304
+ onOpen();
1305
+ }
1306
+ }}
1307
+ className={cn(
1308
+ 'group relative flex h-full w-full cursor-pointer flex-col overflow-hidden rounded-xl border bg-card text-left',
1309
+ 'transition-colors hover:border-foreground/20 hover:shadow-sm',
1310
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
1311
+ isSelected ? 'border-primary ring-2 ring-primary' : 'border-border',
1312
+ )}
1313
+ >
1314
+ <div className="absolute left-2 top-2 z-10">
1315
+ <MultiSelectCheckbox
1316
+ {...checkboxProps}
1317
+ alwaysVisible={isSelected}
1318
+ ariaLabel={isSelected ? 'Deselect note' : 'Select note'}
1319
+ className={cn(
1320
+ 'transition-opacity',
1321
+ !isSelected && 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
1322
+ )}
1323
+ />
1324
+ </div>
1325
+ <div className="relative aspect-[16/10] w-full overflow-hidden">
1326
+ {coverUrl ? (
1327
+ // eslint-disable-next-line @next/next/no-img-element
1328
+ <img src={coverUrl} alt="" className="h-full w-full object-cover" />
1329
+ ) : (
1330
+ <div className="flex h-full w-full bg-card px-5 py-5 text-foreground sm:px-6">
1331
+ <p className="line-clamp-5 text-xl font-semibold leading-snug text-foreground/75 sm:text-2xl">
1332
+ {previewText}
1333
+ </p>
1334
+ </div>
1335
+ )}
1336
+ {status ? (
1337
+ <div className="absolute right-2 top-2">
1338
+ <StatusPill status={status} />
1339
+ </div>
1340
+ ) : null}
1341
+ </div>
1342
+ <div className="flex w-full items-center gap-1.5 border-t border-border px-3 py-2">
1343
+ <NoteIcon icon={note.icon} className="shrink-0 text-muted-foreground" />
1344
+ <span className="line-clamp-1 text-[12.5px] text-foreground">
1345
+ {title}
1346
+ </span>
1347
+ </div>
1348
+ </div>
1349
+ );
1350
+ }
1351
+
1352
+ /* -------------------------------------------------------------------------- */
1353
+ /* Bulk folder picker (Move to / Add to folder) */
1354
+ /* -------------------------------------------------------------------------- */
1355
+
1356
+ function BulkFolderPicker({
1357
+ mode,
1358
+ folderOptions,
1359
+ busy,
1360
+ onPick,
1361
+ onClose,
1362
+ }: {
1363
+ mode: 'move' | 'add';
1364
+ folderOptions: FolderOption[];
1365
+ busy: boolean;
1366
+ onPick: (folderId: string) => void;
1367
+ onClose: () => void;
1368
+ }) {
1369
+ const [query, setQuery] = useState('');
1370
+ const containerRef = useRef<HTMLDivElement>(null);
1371
+ const normalizedQuery = query.trim().toLowerCase();
1372
+ const filtered = normalizedQuery
1373
+ ? folderOptions.filter((f) => f.pathLabel.toLowerCase().includes(normalizedQuery))
1374
+ : folderOptions;
1375
+
1376
+ useEffect(() => {
1377
+ function handlePointerDown(event: Event) {
1378
+ const target = event.target;
1379
+ if (target instanceof Node && containerRef.current?.contains(target)) return;
1380
+ onClose();
1381
+ }
1382
+ const root = containerRef.current?.getRootNode();
1383
+ const eventTarget =
1384
+ root instanceof ShadowRoot || root instanceof Document ? root : document;
1385
+ eventTarget.addEventListener('pointerdown', handlePointerDown);
1386
+ return () => eventTarget.removeEventListener('pointerdown', handlePointerDown);
1387
+ }, [onClose]);
1388
+
1389
+ return (
1390
+ <div
1391
+ ref={containerRef}
1392
+ role="dialog"
1393
+ aria-label={mode === 'move' ? 'Move notes to folder' : 'Add notes to folder'}
1394
+ className="fixed bottom-16 left-1/2 z-[70] w-[320px] -translate-x-1/2 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl"
1395
+ >
1396
+ <div className="mb-2 flex items-center justify-between">
1397
+ <span className="text-[12px] font-semibold text-foreground">
1398
+ {mode === 'move' ? 'Move to folder' : 'Add to folder'}
1399
+ </span>
1400
+ <button
1401
+ type="button"
1402
+ aria-label="Close"
1403
+ onClick={onClose}
1404
+ className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
1405
+ >
1406
+ <X className="h-3.5 w-3.5" />
1407
+ </button>
1408
+ </div>
1409
+ <div className="mb-2 flex items-center gap-2 rounded-md border border-border bg-background px-2">
1410
+ <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1411
+ <input
1412
+ autoFocus
1413
+ value={query}
1414
+ onChange={(event) => setQuery(event.target.value)}
1415
+ placeholder="Search folders"
1416
+ className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none"
1417
+ />
1418
+ </div>
1419
+ <div className="max-h-[220px] overflow-y-auto">
1420
+ {filtered.length ? (
1421
+ filtered.map((folder) => (
1422
+ <button
1423
+ key={folder.id}
1424
+ type="button"
1425
+ disabled={busy}
1426
+ onClick={() => onPick(folder.id)}
1427
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] text-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
1428
+ >
1429
+ <Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1430
+ <span className="truncate">{folder.pathLabel}</span>
1431
+ </button>
1432
+ ))
1433
+ ) : (
1434
+ <p className="py-6 text-center text-sm text-muted-foreground">No folders found.</p>
1435
+ )}
1436
+ </div>
1437
+ </div>
1438
+ );
1439
+ }
1440
+
1441
+ /* -------------------------------------------------------------------------- */
1442
+ /* Table view */
1443
+ /* -------------------------------------------------------------------------- */
1444
+
1445
+ function TableBody({
1446
+ notes,
1447
+ metadataProperties,
1448
+ folderOptions,
1449
+ folderNameById,
1450
+ folderPropertyName,
1451
+ savingNoteId,
1452
+ onOpen,
1453
+ onSaveTitle,
1454
+ onSaveProperties,
1455
+ currentFolderLabel,
1456
+ hasCollectionItem,
1457
+ multiSelect,
1458
+ rowRefs,
1459
+ }: {
1460
+ notes: DocumentRecord[];
1461
+ metadataProperties: DatabaseProperty[];
1462
+ folderOptions: FolderOption[];
1463
+ folderNameById: Map<string, string>;
1464
+ folderPropertyName: string;
1465
+ savingNoteId: string | null;
1466
+ onOpen: (doc: DocumentRecord) => void;
1467
+ onSaveTitle: (id: string, title: string) => void;
1468
+ onSaveProperties: (id: string, props: Record<string, unknown>) => void;
1469
+ currentFolderLabel: string;
1470
+ hasCollectionItem: boolean;
1471
+ multiSelect: MultiSelectController<DocumentRecord>;
1472
+ rowRefs: Map<string, HTMLElement>;
1473
+ }) {
1474
+ const columns = metadataProperties.slice(0, 6);
1475
+ const allSelected =
1476
+ notes.length > 0 && notes.every((note) => multiSelect.isSelected(note.id));
1477
+ const handleSelectAllToggle = () => {
1478
+ if (allSelected) {
1479
+ multiSelect.clear();
1480
+ return;
1481
+ }
1482
+ multiSelect.select(notes.map((note) => note.id));
1483
+ };
1484
+
1485
+ return (
1486
+ <div className="flex flex-1 flex-col gap-4 px-6 py-5">
1487
+ {!notes.length ? (
1488
+ <EmptyState
1489
+ icon={Table2}
1490
+ title={hasCollectionItem ? 'No rows in this folder' : 'No rows yet'}
1491
+ description={
1492
+ hasCollectionItem
1493
+ ? `Create a note in ${currentFolderLabel} to start editing metadata here.`
1494
+ : 'Notes become rows here. Create one to begin editing metadata inline.'
1495
+ }
1496
+ />
1497
+ ) : (
1498
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
1499
+ <div {...multiSelect.getContainerProps()} className="overflow-x-auto">
1500
+ <table className="w-full min-w-[1040px] border-collapse text-[13px]">
1501
+ <thead>
1502
+ <tr className="border-b border-border bg-muted/40">
1503
+ <th className="w-10 px-3 py-2.5">
1504
+ <MultiSelectCheckbox
1505
+ isSelected={allSelected}
1506
+ onClick={(e) => {
1507
+ e.stopPropagation();
1508
+ handleSelectAllToggle();
1509
+ }}
1510
+ alwaysVisible
1511
+ ariaLabel={allSelected ? 'Deselect all notes' : 'Select all notes'}
1512
+ />
1513
+ </th>
1514
+ <th className="px-3 py-2.5 text-left">
1515
+ <div className="flex items-center gap-1.5">
1516
+ <FileText className="h-3 w-3 text-muted-foreground" />
1517
+ <Eyebrow>Note</Eyebrow>
1518
+ </div>
1519
+ </th>
1520
+ {columns.map((p) => (
1521
+ <th key={p.name} className="border-l border-border px-3 py-2.5 text-left">
1522
+ <Eyebrow>{p.name}</Eyebrow>
1523
+ </th>
1524
+ ))}
1525
+ <th className="w-10 border-l border-border" />
1526
+ </tr>
1527
+ </thead>
1528
+ <tbody>
1529
+ {notes.map((note) => (
1530
+ <TableRow
1531
+ key={note.id}
1532
+ note={note}
1533
+ columns={columns}
1534
+ folderOptions={folderOptions}
1535
+ folderNameById={folderNameById}
1536
+ folderPropertyName={folderPropertyName}
1537
+ saving={savingNoteId === note.id}
1538
+ onOpen={() => onOpen(note)}
1539
+ onSaveTitle={(title) => onSaveTitle(note.id, title)}
1540
+ onSaveProperties={(props) => onSaveProperties(note.id, props)}
1541
+ isSelected={multiSelect.isSelected(note.id)}
1542
+ onCheckboxClick={multiSelect.onCheckboxClick(note.id)}
1543
+ onRowMouseDown={multiSelect.onRowMouseDown(note.id)}
1544
+ rowProps={multiSelect.getRowProps(note.id)}
1545
+ rowRef={(node) => {
1546
+ if (node) {
1547
+ rowRefs.set(note.id, node);
1548
+ } else {
1549
+ rowRefs.delete(note.id);
1550
+ }
1551
+ }}
1552
+ />
1553
+ ))}
1554
+ </tbody>
1555
+ </table>
1556
+ </div>
1557
+ </div>
1558
+ )}
1559
+ </div>
1560
+ );
1561
+ }
1562
+
1563
+ function TableRow({
1564
+ note,
1565
+ columns,
1566
+ folderOptions,
1567
+ folderNameById,
1568
+ folderPropertyName,
1569
+ saving,
1570
+ onOpen,
1571
+ onSaveTitle,
1572
+ onSaveProperties,
1573
+ isSelected,
1574
+ onCheckboxClick,
1575
+ onRowMouseDown,
1576
+ rowProps,
1577
+ rowRef,
1578
+ }: {
1579
+ note: DocumentRecord;
1580
+ columns: DatabaseProperty[];
1581
+ folderOptions: FolderOption[];
1582
+ folderNameById: Map<string, string>;
1583
+ folderPropertyName: string;
1584
+ saving: boolean;
1585
+ onOpen: () => void;
1586
+ onSaveTitle: (title: string) => void;
1587
+ onSaveProperties: (props: Record<string, unknown>) => void;
1588
+ isSelected: boolean;
1589
+ onCheckboxClick: (event: React.MouseEvent) => void;
1590
+ onRowMouseDown: (event: React.MouseEvent) => void;
1591
+ rowProps: Record<string, string>;
1592
+ rowRef: (node: HTMLTableRowElement | null) => void;
1593
+ }) {
1594
+ return (
1595
+ <tr
1596
+ {...rowProps}
1597
+ ref={rowRef}
1598
+ className={cn(
1599
+ 'group border-b border-border last:border-b-0 transition-colors',
1600
+ saving ? 'bg-muted/20' : 'hover:bg-muted/30',
1601
+ isSelected && 'bg-primary/5 hover:bg-primary/10',
1602
+ )}
1603
+ onClick={onOpen}
1604
+ onMouseDown={onRowMouseDown}
1605
+ onKeyDown={(e) => {
1606
+ if (e.key === 'Enter') {
1607
+ e.preventDefault();
1608
+ onOpen();
1609
+ }
1610
+ }}
1611
+ tabIndex={0}
1612
+ >
1613
+ <td className="px-3 py-2">
1614
+ <MultiSelectCheckbox
1615
+ isSelected={isSelected}
1616
+ onClick={onCheckboxClick}
1617
+ alwaysVisible={isSelected}
1618
+ className={cn(
1619
+ 'transition-opacity',
1620
+ !isSelected && 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
1621
+ )}
1622
+ />
1623
+ </td>
1624
+ <td className="px-3 py-2">
1625
+ <div className="flex items-center gap-2">
1626
+ <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md bg-muted">
1627
+ <FileText className="h-3 w-3 text-muted-foreground" />
1628
+ </div>
1629
+ <input
1630
+ type="text"
1631
+ defaultValue={getNoteTitle(note)}
1632
+ key={`${note.id}:title:${getNoteTitle(note)}`}
1633
+ onClick={(e) => e.stopPropagation()}
1634
+ onKeyDown={(e) => {
1635
+ if (e.key === 'Enter') e.currentTarget.blur();
1636
+ }}
1637
+ onBlur={(e) => onSaveTitle(e.target.value)}
1638
+ className="w-full rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[13px] font-medium text-foreground outline-none transition-colors focus:border-border focus:bg-background"
1639
+ />
1640
+ <ArrowUpRight className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-60" />
1641
+ </div>
1642
+ </td>
1643
+ {columns.map((prop) => (
1644
+ <td key={prop.name} className="border-l border-border px-3 py-2 align-middle">
1645
+ <TableCell
1646
+ note={note}
1647
+ property={prop}
1648
+ folderOptions={folderOptions}
1649
+ folderNameById={folderNameById}
1650
+ folderPropertyName={folderPropertyName}
1651
+ onSaveProperties={onSaveProperties}
1652
+ />
1653
+ </td>
1654
+ ))}
1655
+ <td className="w-10 border-l border-border" />
1656
+ </tr>
1657
+ );
1658
+ }
1659
+
1660
+ function TableCell({
1661
+ note,
1662
+ property,
1663
+ folderOptions,
1664
+ folderNameById,
1665
+ folderPropertyName,
1666
+ onSaveProperties,
1667
+ }: {
1668
+ note: DocumentRecord;
1669
+ property: DatabaseProperty;
1670
+ folderOptions: FolderOption[];
1671
+ folderNameById: Map<string, string>;
1672
+ folderPropertyName: string;
1673
+ onSaveProperties: (props: Record<string, unknown>) => void;
1674
+ }) {
1675
+ const value = note.properties[property.name];
1676
+ const selectClass =
1677
+ 'w-full appearance-none rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[12px] text-foreground outline-none transition-colors focus:border-border focus:bg-background';
1678
+
1679
+ if (property.type === 'status' || property.type === 'select') {
1680
+ const label = isPresentString(value) ? value : '';
1681
+ return (
1682
+ <div className="relative inline-flex items-center" onClick={(e) => e.stopPropagation()}>
1683
+ {label ? (
1684
+ <StatusPill status={label} />
1685
+ ) : (
1686
+ <span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">None</span>
1687
+ )}
1688
+ <select
1689
+ aria-label={property.name}
1690
+ className="absolute inset-0 cursor-pointer opacity-0"
1691
+ value={label}
1692
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.value || null })}
1693
+ >
1694
+ <option value="">None</option>
1695
+ {(property.options ?? []).map((opt) => (
1696
+ <option key={opt.id ?? opt.name} value={opt.name}>
1697
+ {opt.name}
1698
+ </option>
1699
+ ))}
1700
+ </select>
1701
+ </div>
1702
+ );
1703
+ }
1704
+
1705
+ if (property.type === 'date') {
1706
+ return (
1707
+ <input
1708
+ type="date"
1709
+ value={getDateInputValue(value)}
1710
+ onClick={(e) => e.stopPropagation()}
1711
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.value || null })}
1712
+ className={cn(selectClass, 'text-[12px]')}
1713
+ />
1714
+ );
1715
+ }
1716
+
1717
+ if (property.type === 'checkbox') {
1718
+ return (
1719
+ <label
1720
+ className="inline-flex items-center"
1721
+ onClick={(e) => e.stopPropagation()}
1722
+ >
1723
+ <input
1724
+ type="checkbox"
1725
+ checked={Boolean(value)}
1726
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.checked })}
1727
+ className="h-3.5 w-3.5 rounded border-border"
1728
+ />
1729
+ </label>
1730
+ );
1731
+ }
1732
+
1733
+ if (property.type === 'relation' && property.name === folderPropertyName) {
1734
+ const currentId = getRelationIds(value)[0] ?? '';
1735
+ return (
1736
+ <select
1737
+ className={cn(selectClass, 'text-[12px]')}
1738
+ value={currentId}
1739
+ onClick={(e) => e.stopPropagation()}
1740
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.value ? [e.target.value] : [] })}
1741
+ >
1742
+ <option value="">No folder</option>
1743
+ {folderOptions.map((f) => (
1744
+ <option key={f.id} value={f.id}>
1745
+ {f.pathLabel}
1746
+ </option>
1747
+ ))}
1748
+ </select>
1749
+ );
1750
+ }
1751
+
1752
+ if (property.type === 'number') {
1753
+ return (
1754
+ <input
1755
+ type="number"
1756
+ defaultValue={typeof value === 'number' ? String(value) : ''}
1757
+ inputMode="decimal"
1758
+ key={`${note.id}:${property.name}:${String(value ?? '')}`}
1759
+ onClick={(e) => e.stopPropagation()}
1760
+ onKeyDown={(e) => {
1761
+ if (e.key === 'Enter') e.currentTarget.blur();
1762
+ }}
1763
+ onBlur={(e) => {
1764
+ const v = e.target.value.trim();
1765
+ onSaveProperties({ [property.name]: v ? Number(v) : null });
1766
+ }}
1767
+ className={cn(selectClass, 'font-mono text-[12px]')}
1768
+ />
1769
+ );
1770
+ }
1771
+
1772
+ if (property.type === 'rich_text') {
1773
+ return (
1774
+ <input
1775
+ type="text"
1776
+ defaultValue={isPresentString(value) ? value : ''}
1777
+ key={`${note.id}:${property.name}:${String(value ?? '')}`}
1778
+ onClick={(e) => e.stopPropagation()}
1779
+ onKeyDown={(e) => {
1780
+ if (e.key === 'Enter') e.currentTarget.blur();
1781
+ }}
1782
+ onBlur={(e) => {
1783
+ const v = e.target.value.trim();
1784
+ onSaveProperties({ [property.name]: v || null });
1785
+ }}
1786
+ className={cn(selectClass, 'text-[12px]')}
1787
+ />
1788
+ );
1789
+ }
1790
+
1791
+ return (
1792
+ <span className="px-1.5 text-[12px] text-muted-foreground">
1793
+ {formatPropertyValue(value, property, folderNameById)}
1794
+ </span>
1795
+ );
1796
+ }
1797
+
1798
+ /* -------------------------------------------------------------------------- */
1799
+ /* Calendar view */
1800
+ /* -------------------------------------------------------------------------- */
1801
+
1802
+ function CalendarBody({
1803
+ monthDays,
1804
+ visibleMonth,
1805
+ setVisibleMonth,
1806
+ notesByDay,
1807
+ statusPropertyName,
1808
+ calendarDateFields,
1809
+ activeDateProperty,
1810
+ setActiveDateProperty,
1811
+ scheduledNotesCount,
1812
+ onOpen,
1813
+ }: {
1814
+ monthDays: Date[];
1815
+ visibleMonth: Date;
1816
+ setVisibleMonth: (d: Date) => void;
1817
+ notesByDay: Map<string, DocumentRecord[]>;
1818
+ statusPropertyName: string | null;
1819
+ calendarDateFields: CalendarDateField[];
1820
+ activeDateProperty: string;
1821
+ setActiveDateProperty: (v: string) => void;
1822
+ scheduledNotesCount: number;
1823
+ onOpen: (doc: DocumentRecord) => void;
1824
+ }) {
1825
+ return (
1826
+ <div className="flex flex-1 flex-col gap-4 px-6 py-5">
1827
+ <div className="flex flex-wrap items-center gap-2">
1828
+ <div className="inline-flex items-center gap-0 rounded-lg border border-border bg-background p-0.5">
1829
+ <Button
1830
+ variant="ghost"
1831
+ size="icon"
1832
+ className="h-7 w-7"
1833
+ onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1))}
1834
+ >
1835
+ <ChevronLeft className="h-3.5 w-3.5" />
1836
+ </Button>
1837
+ <div className="px-2 text-[13px] font-semibold tracking-tight text-foreground">
1838
+ {formatMonthLabel(visibleMonth)}
1839
+ </div>
1840
+ <Button
1841
+ variant="ghost"
1842
+ size="icon"
1843
+ className="h-7 w-7"
1844
+ onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1))}
1845
+ >
1846
+ <ChevronRight className="h-3.5 w-3.5" />
1847
+ </Button>
1848
+ </div>
1849
+ <Button variant="outline" size="sm" className="h-8 text-[12px]" onClick={() => setVisibleMonth(new Date())}>
1850
+ Today
1851
+ </Button>
1852
+
1853
+ <div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px]">
1854
+ <CalendarDays className="h-3 w-3 text-muted-foreground" />
1855
+ <span className="text-muted-foreground">Date field</span>
1856
+ <select
1857
+ className="appearance-none bg-transparent pr-3 font-semibold text-foreground outline-none"
1858
+ value={activeDateProperty}
1859
+ onChange={(e) => setActiveDateProperty(e.target.value)}
1860
+ >
1861
+ {calendarDateFields.map((field) => (
1862
+ <option key={field.value} value={field.value}>
1863
+ {field.label}
1864
+ </option>
1865
+ ))}
1866
+ </select>
1867
+ <ChevronDown className="h-3 w-3 text-muted-foreground" />
1868
+ </div>
1869
+
1870
+ <div className="flex-1" />
1871
+ <Eyebrow>{pluralize(scheduledNotesCount, 'note')} scheduled</Eyebrow>
1872
+ </div>
1873
+
1874
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
1875
+ <div className="grid grid-cols-7 border-b border-border bg-muted/40">
1876
+ {WEEKDAY_LABELS.map((label, i) => (
1877
+ <div
1878
+ key={label}
1879
+ className={cn(
1880
+ 'px-3 py-2 font-mono text-[10px] font-medium uppercase tracking-[0.14em]',
1881
+ i >= 5 ? 'text-muted-foreground/60' : 'text-muted-foreground',
1882
+ )}
1883
+ >
1884
+ {label}
1885
+ </div>
1886
+ ))}
1887
+ </div>
1888
+ <div className="grid grid-cols-7">
1889
+ {monthDays.map((day) => {
1890
+ const key = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`;
1891
+ const dayNotes = notesByDay.get(key) ?? [];
1892
+ const inMonth = isSameMonth(day, visibleMonth);
1893
+ const today = isToday(day);
1894
+ return (
1895
+ <div
1896
+ key={key}
1897
+ className={cn(
1898
+ 'flex min-h-[110px] flex-col gap-1.5 border-b border-r border-border px-2 py-2',
1899
+ !inMonth && 'bg-muted/30',
1900
+ )}
1901
+ >
1902
+ <div className="flex items-center justify-between">
1903
+ {today ? (
1904
+ <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-foreground text-[11px] font-semibold text-background">
1905
+ {day.getDate()}
1906
+ </span>
1907
+ ) : (
1908
+ <span
1909
+ className={cn(
1910
+ 'text-[12px] font-medium',
1911
+ inMonth ? 'text-foreground/80' : 'text-muted-foreground/50',
1912
+ )}
1913
+ >
1914
+ {day.getDate()}
1915
+ </span>
1916
+ )}
1917
+ {today ? <Eyebrow className="text-foreground">Today</Eyebrow> : null}
1918
+ </div>
1919
+ <div className="flex flex-col gap-1">
1920
+ {dayNotes.slice(0, 3).map((note) => {
1921
+ const status = statusPropertyName
1922
+ ? getStatusLabel(note.properties[statusPropertyName])
1923
+ : null;
1924
+ const tone = getStatusTone(status);
1925
+ return (
1926
+ <button
1927
+ key={note.id}
1928
+ type="button"
1929
+ onClick={() => onOpen(note)}
1930
+ className={cn(
1931
+ 'line-clamp-1 rounded-md border border-l-2 border-border bg-background px-2 py-1 text-left text-[11px] font-medium text-foreground transition-colors hover:bg-muted/50',
1932
+ statusBarClasses[tone],
1933
+ )}
1934
+ >
1935
+ {getNoteTitle(note)}
1936
+ </button>
1937
+ );
1938
+ })}
1939
+ {dayNotes.length > 3 ? (
1940
+ <span className="px-1 text-[10px] text-muted-foreground">+{dayNotes.length - 3} more</span>
1941
+ ) : null}
1942
+ </div>
1943
+ </div>
1944
+ );
1945
+ })}
1946
+ </div>
1947
+ </div>
1948
+ </div>
1949
+ );
1950
+ }