@notis_ai/cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +122 -158
  2. package/dist/scaffolds/notes/app/globals.css +3 -0
  3. package/dist/scaffolds/notes/app/layout.tsx +6 -0
  4. package/dist/scaffolds/notes/app/page.tsx +1465 -0
  5. package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
  6. package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
  7. package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
  8. package/dist/scaffolds/notes/components.json +20 -0
  9. package/dist/scaffolds/notes/lib/utils.ts +6 -0
  10. package/dist/scaffolds/notes/notis.config.ts +31 -0
  11. package/dist/scaffolds/notes/package.json +31 -0
  12. package/dist/scaffolds/notes/postcss.config.mjs +8 -0
  13. package/dist/scaffolds/notes/tailwind.config.ts +58 -0
  14. package/dist/scaffolds/notes/tsconfig.json +22 -0
  15. package/dist/scaffolds/notes/vite.config.ts +10 -0
  16. package/dist/scaffolds.json +13 -0
  17. package/package.json +12 -2
  18. package/skills/notis-apps/SKILL.md +162 -0
  19. package/skills/notis-apps/cli.md +208 -0
  20. package/skills/notis-cli/SKILL.md +258 -0
  21. package/skills/notis-query/cli.md +40 -0
  22. package/src/cli.js +1 -1
  23. package/src/command-specs/apps.js +1029 -59
  24. package/src/command-specs/helpers.js +6 -41
  25. package/src/command-specs/index.js +1 -7
  26. package/src/command-specs/meta.js +7 -6
  27. package/src/command-specs/tools.js +29 -34
  28. package/src/runtime/agent-browser.js +192 -0
  29. package/src/runtime/app-boundary-validator.js +132 -0
  30. package/src/runtime/app-dev-server.js +605 -0
  31. package/src/runtime/app-dev-sessions.js +87 -0
  32. package/src/runtime/app-platform.js +1037 -82
  33. package/src/runtime/cli-mode.generated.js +4 -0
  34. package/src/runtime/cli-mode.js +29 -0
  35. package/src/runtime/output.js +2 -2
  36. package/src/runtime/ports.js +15 -0
  37. package/src/runtime/profiles.js +36 -5
  38. package/src/runtime/transport.js +131 -6
  39. package/template/.harness/index.html.tmpl +211 -0
  40. package/template/app/globals.css +3 -0
  41. package/template/app/layout.tsx +6 -0
  42. package/template/app/page.tsx +90 -0
  43. package/template/components/ui/badge.tsx +28 -0
  44. package/template/components/ui/button.tsx +53 -0
  45. package/template/components/ui/card.tsx +56 -0
  46. package/template/components.json +20 -0
  47. package/template/lib/utils.ts +6 -0
  48. package/template/metadata/cover.png +0 -0
  49. package/template/metadata/screenshot-1.png +0 -0
  50. package/template/metadata/screenshot-2.png +0 -0
  51. package/template/metadata/screenshot-3.png +0 -0
  52. package/template/notis.config.ts +37 -0
  53. package/template/package.json +31 -0
  54. package/template/packages/sdk/package.json +32 -0
  55. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
  56. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  57. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  58. package/template/packages/sdk/src/config.ts +71 -0
  59. package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
  60. package/template/packages/sdk/src/hooks/useDatabase.ts +76 -0
  61. package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
  62. package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
  63. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  64. package/template/packages/sdk/src/hooks/useTool.ts +64 -0
  65. package/template/packages/sdk/src/hooks/useTools.ts +56 -0
  66. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  67. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
  68. package/template/packages/sdk/src/index.ts +54 -0
  69. package/template/packages/sdk/src/provider.tsx +43 -0
  70. package/template/packages/sdk/src/runtime.ts +161 -0
  71. package/template/packages/sdk/src/styles.css +38 -0
  72. package/template/packages/sdk/src/ui.ts +15 -0
  73. package/template/packages/sdk/src/vite.ts +56 -0
  74. package/template/packages/sdk/tsconfig.json +15 -0
  75. package/template/postcss.config.mjs +8 -0
  76. package/template/tailwind.config.ts +58 -0
  77. package/template/tsconfig.json +22 -0
  78. package/template/vite.config.ts +10 -0
  79. package/src/command-specs/auth.js +0 -178
  80. package/src/command-specs/db.js +0 -163
  81. package/src/runtime/app-preview-server.js +0 -272
@@ -0,0 +1,1465 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import {
5
+ MultiSelectActionBar,
6
+ MultiSelectCheckbox,
7
+ MultiSelectDragOverlay,
8
+ useDatabase,
9
+ useMultiSelect,
10
+ useNotis,
11
+ useNotisRuntime,
12
+ useTopBarSearch,
13
+ useUpsertDocument,
14
+ type DatabaseProperty,
15
+ type DocumentRecord,
16
+ type MultiSelectController,
17
+ } from '@notis/sdk';
18
+ import {
19
+ ArrowUpDown,
20
+ ArrowUpRight,
21
+ Boxes,
22
+ CalendarDays,
23
+ ChevronDown,
24
+ ChevronLeft,
25
+ ChevronRight,
26
+ Eye,
27
+ FileText,
28
+ FolderMinus,
29
+ LayoutGrid,
30
+ Link2,
31
+ Loader2,
32
+ Maximize2,
33
+ Plus,
34
+ Rows3,
35
+ Search,
36
+ Settings2,
37
+ SlidersHorizontal,
38
+ Sparkles,
39
+ Table2,
40
+ type LucideIcon,
41
+ } from 'lucide-react';
42
+ import { DynamicIcon, type IconName } from 'lucide-react/dynamic';
43
+
44
+ import { Button } from '@/components/ui/button';
45
+ import { cn } from '@/lib/utils';
46
+
47
+ type TabKey = 'gallery' | 'table' | 'calendar';
48
+
49
+ type TabConfig = {
50
+ key: TabKey;
51
+ label: string;
52
+ icon: LucideIcon;
53
+ };
54
+
55
+ type FolderOption = {
56
+ id: string;
57
+ title: string;
58
+ parentId: string | null;
59
+ pathLabel: string;
60
+ };
61
+
62
+ const NOTE_DATABASE_SLUG = 'notes';
63
+ const FOLDER_DATABASE_SLUG = 'note_folders';
64
+ const DEFAULT_NOTE_TITLE = 'Untitled note';
65
+ const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
66
+
67
+ const TABS: TabConfig[] = [
68
+ { key: 'gallery', label: 'Gallery', icon: LayoutGrid },
69
+ { key: 'table', label: 'Table', icon: Table2 },
70
+ { key: 'calendar', label: 'Calendar', icon: CalendarDays },
71
+ ];
72
+
73
+ function isPresentString(value: unknown): value is string {
74
+ return typeof value === 'string' && value.trim().length > 0;
75
+ }
76
+
77
+ function pluralize(count: number, singular: string, plural = `${singular}s`): string {
78
+ return `${count} ${count === 1 ? singular : plural}`;
79
+ }
80
+
81
+ function getDateKey(value: unknown): string | null {
82
+ if (!isPresentString(value)) return null;
83
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})/);
84
+ return match ? match[1] : null;
85
+ }
86
+
87
+ function getDateInputValue(value: unknown): string {
88
+ return getDateKey(value) ?? '';
89
+ }
90
+
91
+ function getRelationIds(value: unknown): string[] {
92
+ if (!Array.isArray(value)) return [];
93
+ return value.filter((item): item is string => isPresentString(item));
94
+ }
95
+
96
+ function getFolderTitle(document: DocumentRecord): string {
97
+ const explicit = document.properties.Name;
98
+ if (isPresentString(explicit)) return explicit;
99
+ return document.title || 'Untitled folder';
100
+ }
101
+
102
+ function getNoteTitle(document: DocumentRecord): string {
103
+ return isPresentString(document.title) ? document.title : DEFAULT_NOTE_TITLE;
104
+ }
105
+
106
+ function getPlainTextFromBlockNote(value: unknown): string {
107
+ const segments: string[] = [];
108
+
109
+ function visit(node: unknown): void {
110
+ if (typeof node === 'string') {
111
+ if (node.trim()) segments.push(node.trim());
112
+ return;
113
+ }
114
+ if (Array.isArray(node)) {
115
+ node.forEach(visit);
116
+ return;
117
+ }
118
+ if (!node || typeof node !== 'object') return;
119
+
120
+ const record = node as Record<string, unknown>;
121
+ if (typeof record.text === 'string' && record.text.trim()) {
122
+ segments.push(record.text.trim());
123
+ }
124
+ visit(record.content);
125
+ visit(record.children);
126
+ }
127
+
128
+ visit(value);
129
+ return segments.join(' ').replace(/\s+/g, ' ').trim();
130
+ }
131
+
132
+ function getNotePreviewText(document: DocumentRecord): string {
133
+ if (isPresentString(document.plainText)) {
134
+ return document.plainText.replace(/\s+/g, ' ').trim();
135
+ }
136
+ if (isPresentString(document.contentMarkdown)) {
137
+ return document.contentMarkdown
138
+ .replace(/[#*_`>~[\]()]/g, ' ')
139
+ .replace(/\s+/g, ' ')
140
+ .trim();
141
+ }
142
+ const fromBlocks = getPlainTextFromBlockNote(document.contentBlocknote);
143
+ if (fromBlocks) return fromBlocks;
144
+ return getNoteTitle(document);
145
+ }
146
+
147
+ function buildFolderOptions(documents: DocumentRecord[]): FolderOption[] {
148
+ const byId = new Map(documents.map((d) => [d.id, d]));
149
+
150
+ function buildPath(id: string, seen: Set<string> = new Set()): string {
151
+ const folder = byId.get(id);
152
+ if (!folder) return 'Untitled folder';
153
+ const title = getFolderTitle(folder);
154
+ if (seen.has(id)) return title;
155
+ seen.add(id);
156
+ const parentId = getRelationIds(folder.properties.Parent)[0] ?? null;
157
+ if (!parentId || !byId.has(parentId)) return title;
158
+ return `${buildPath(parentId, seen)} / ${title}`;
159
+ }
160
+
161
+ return documents
162
+ .map((d) => ({
163
+ id: d.id,
164
+ title: getFolderTitle(d),
165
+ parentId: getRelationIds(d.properties.Parent)[0] ?? null,
166
+ pathLabel: buildPath(d.id),
167
+ }))
168
+ .sort((a, b) => a.pathLabel.localeCompare(b.pathLabel));
169
+ }
170
+
171
+ function getCoverUrl(document: DocumentRecord): string | null {
172
+ if (isPresentString(document.cover)) {
173
+ return document.cover.trim();
174
+ }
175
+ return null;
176
+ }
177
+
178
+ function NoteIcon({
179
+ icon,
180
+ className,
181
+ fallbackClassName,
182
+ }: {
183
+ icon: string | null | undefined;
184
+ className?: string;
185
+ fallbackClassName?: string;
186
+ }) {
187
+ if (typeof icon === 'string' && icon.startsWith('lucide:')) {
188
+ const name = icon.slice('lucide:'.length).trim().replace(/_/g, '-');
189
+ if (name) {
190
+ return (
191
+ <DynamicIcon
192
+ name={name as IconName}
193
+ className={cn('h-3.5 w-3.5', className)}
194
+ fallback={() => <FileText className={cn('h-3.5 w-3.5', className, fallbackClassName)} />}
195
+ />
196
+ );
197
+ }
198
+ }
199
+ if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
200
+ return (
201
+ <img
202
+ src={icon.trim()}
203
+ alt=""
204
+ className={cn('h-3.5 w-3.5 rounded-sm object-cover', className)}
205
+ />
206
+ );
207
+ }
208
+ return <FileText className={cn('h-3.5 w-3.5', className, fallbackClassName)} />;
209
+ }
210
+
211
+ function formatDateLabel(value: unknown): string {
212
+ const key = getDateKey(value);
213
+ if (!key) return 'No date';
214
+ const date = new Date(`${key}T12:00:00`);
215
+ return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date);
216
+ }
217
+
218
+ function formatPropertyValue(
219
+ value: unknown,
220
+ property: DatabaseProperty | null,
221
+ folderNameById: Map<string, string>,
222
+ ): string {
223
+ if (property?.type === 'relation') {
224
+ const ids = getRelationIds(value);
225
+ if (!ids.length) return 'None';
226
+ return ids.map((id) => folderNameById.get(id) ?? id).join(', ');
227
+ }
228
+ if (property?.type === 'date') return formatDateLabel(value);
229
+ if (Array.isArray(value)) return value.length ? value.map((v) => String(v)).join(', ') : 'None';
230
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
231
+ if (typeof value === 'number') return String(value);
232
+ if (isPresentString(value)) return value;
233
+ return 'None';
234
+ }
235
+
236
+ function formatMonthLabel(month: Date): string {
237
+ return new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(month);
238
+ }
239
+
240
+ function startOfCalendarGrid(month: Date): Date {
241
+ const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
242
+ const mondayIndex = (first.getDay() + 6) % 7;
243
+ const start = new Date(first);
244
+ start.setDate(first.getDate() - mondayIndex);
245
+ return start;
246
+ }
247
+
248
+ function buildCalendarDays(month: Date): Date[] {
249
+ const start = startOfCalendarGrid(month);
250
+ return Array.from({ length: 42 }, (_, i) => {
251
+ const d = new Date(start);
252
+ d.setDate(start.getDate() + i);
253
+ return d;
254
+ });
255
+ }
256
+
257
+ function isSameMonth(a: Date, b: Date): boolean {
258
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
259
+ }
260
+
261
+ function isToday(day: Date): boolean {
262
+ const t = new Date();
263
+ return day.getFullYear() === t.getFullYear() && day.getMonth() === t.getMonth() && day.getDate() === t.getDate();
264
+ }
265
+
266
+ function getStatusLabel(value: unknown): string | null {
267
+ return isPresentString(value) ? value : null;
268
+ }
269
+
270
+ type StatusTone = 'active' | 'review' | 'done' | 'blocked' | 'idea' | 'neutral';
271
+
272
+ function getStatusTone(status: string | null): StatusTone {
273
+ if (!status) return 'neutral';
274
+ const s = status.toLowerCase();
275
+ if (s.includes('done') || s.includes('complete') || s.includes('ship')) return 'done';
276
+ if (s.includes('progress') || s.includes('active') || s.includes('draft')) return 'active';
277
+ if (s.includes('review') || s.includes('wait') || s.includes('hold')) return 'review';
278
+ if (s.includes('block') || s.includes('stuck')) return 'blocked';
279
+ if (s.includes('idea') || s.includes('backlog')) return 'idea';
280
+ return 'neutral';
281
+ }
282
+
283
+ const statusPillClasses: Record<StatusTone, string> = {
284
+ active: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
285
+ review: 'bg-blue-500/10 text-blue-700 dark:text-blue-400',
286
+ done: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
287
+ blocked: 'bg-red-500/10 text-red-700 dark:text-red-400',
288
+ idea: 'bg-muted text-muted-foreground',
289
+ neutral: 'bg-muted text-muted-foreground',
290
+ };
291
+
292
+ const statusDotClasses: Record<StatusTone, string> = {
293
+ active: 'bg-amber-500',
294
+ review: 'bg-blue-500',
295
+ done: 'bg-emerald-500',
296
+ blocked: 'bg-red-500',
297
+ idea: 'bg-stone-400',
298
+ neutral: 'bg-stone-400',
299
+ };
300
+
301
+ const statusBarClasses: Record<StatusTone, string> = {
302
+ active: 'border-l-amber-500',
303
+ review: 'border-l-blue-500',
304
+ done: 'border-l-emerald-500',
305
+ blocked: 'border-l-red-500',
306
+ idea: 'border-l-stone-400',
307
+ neutral: 'border-l-stone-400',
308
+ };
309
+
310
+ function StatusPill({ status }: { status: string | null }) {
311
+ if (!status) return null;
312
+ const tone = getStatusTone(status);
313
+ return (
314
+ <span
315
+ className={cn(
316
+ 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-tight',
317
+ statusPillClasses[tone],
318
+ )}
319
+ >
320
+ <span className={cn('h-1.5 w-1.5 rounded-full', statusDotClasses[tone])} />
321
+ {status}
322
+ </span>
323
+ );
324
+ }
325
+
326
+ function Eyebrow({ children, className }: { children: React.ReactNode; className?: string }) {
327
+ return (
328
+ <span
329
+ className={cn(
330
+ 'font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground',
331
+ className,
332
+ )}
333
+ >
334
+ {children}
335
+ </span>
336
+ );
337
+ }
338
+
339
+ function EmptyState({
340
+ icon: Icon,
341
+ title,
342
+ description,
343
+ action,
344
+ }: {
345
+ icon: LucideIcon;
346
+ title: string;
347
+ description: string;
348
+ action?: React.ReactNode;
349
+ }) {
350
+ return (
351
+ <div className="flex flex-col items-center justify-center px-6 py-16 text-center">
352
+ <Icon className="mb-4 h-10 w-10 stroke-[1.5] text-muted-foreground/30" />
353
+ <p className="text-sm font-semibold text-foreground">{title}</p>
354
+ <p className="mt-1 max-w-sm text-[13px] text-muted-foreground">{description}</p>
355
+ {action ? <div className="mt-5">{action}</div> : null}
356
+ </div>
357
+ );
358
+ }
359
+
360
+ function LoadingState() {
361
+ return (
362
+ <div className="flex min-h-[360px] items-center justify-center gap-2 text-sm text-muted-foreground">
363
+ <Loader2 className="h-4 w-4 animate-spin" />
364
+ Loading notes…
365
+ </div>
366
+ );
367
+ }
368
+
369
+ function PageIcon({ icon }: { icon: string | null | undefined }) {
370
+ if (typeof icon === 'string' && icon.startsWith('lucide:')) {
371
+ const name = icon.slice('lucide:'.length).trim().replace(/_/g, '-');
372
+ if (name) {
373
+ return (
374
+ <DynamicIcon
375
+ name={name as IconName}
376
+ className="h-7 w-7 stroke-[1.5] text-foreground"
377
+ fallback={() => <Boxes className="h-7 w-7 stroke-[1.5] text-foreground" />}
378
+ />
379
+ );
380
+ }
381
+ }
382
+ if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
383
+ return (
384
+ <img
385
+ src={icon.trim()}
386
+ alt=""
387
+ className="h-7 w-7 rounded-md object-cover"
388
+ />
389
+ );
390
+ }
391
+ return <Boxes className="h-7 w-7 stroke-[1.5] text-foreground" />;
392
+ }
393
+
394
+ function ToolbarIconButton({
395
+ icon: Icon,
396
+ label,
397
+ }: {
398
+ icon: LucideIcon;
399
+ label: string;
400
+ }) {
401
+ return (
402
+ <button
403
+ type="button"
404
+ aria-label={label}
405
+ aria-disabled="true"
406
+ tabIndex={-1}
407
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
408
+ >
409
+ <Icon className="h-3.5 w-3.5" />
410
+ </button>
411
+ );
412
+ }
413
+
414
+ function ViewPill({
415
+ config,
416
+ active,
417
+ onSelect,
418
+ }: {
419
+ config: TabConfig;
420
+ active: boolean;
421
+ onSelect: () => void;
422
+ }) {
423
+ const Icon = config.icon;
424
+ return (
425
+ <button
426
+ type="button"
427
+ onClick={onSelect}
428
+ className={cn(
429
+ 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[12.5px] font-medium transition-colors',
430
+ active
431
+ ? 'bg-muted text-foreground'
432
+ : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
433
+ )}
434
+ >
435
+ <Icon className="h-3.5 w-3.5" />
436
+ {config.label}
437
+ </button>
438
+ );
439
+ }
440
+
441
+ function PageHeader({
442
+ title,
443
+ icon,
444
+ activeTab,
445
+ onTabChange,
446
+ }: {
447
+ title: string;
448
+ icon: string | null;
449
+ activeTab: TabKey;
450
+ onTabChange: (tab: TabKey) => void;
451
+ }) {
452
+ return (
453
+ <div className="flex flex-col gap-3 px-6 pt-6">
454
+ <PageIcon icon={icon} />
455
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">{title}</h1>
456
+ <div className="flex flex-wrap items-center gap-2 border-b border-border pb-2">
457
+ <div className="flex items-center gap-1">
458
+ {TABS.map((tab) => (
459
+ <ViewPill
460
+ key={tab.key}
461
+ config={tab}
462
+ active={activeTab === tab.key}
463
+ onSelect={() => onTabChange(tab.key)}
464
+ />
465
+ ))}
466
+ </div>
467
+ <div className="ml-auto flex items-center gap-0.5">
468
+ <ToolbarIconButton icon={SlidersHorizontal} label="Filter" />
469
+ <ToolbarIconButton icon={ArrowUpDown} label="Sort" />
470
+ <ToolbarIconButton icon={Link2} label="Copy link" />
471
+ <ToolbarIconButton icon={Search} label="Search" />
472
+ <ToolbarIconButton icon={Eye} label="Views" />
473
+ <ToolbarIconButton icon={Maximize2} label="Expand" />
474
+ <button
475
+ type="button"
476
+ aria-label="New"
477
+ aria-disabled="true"
478
+ tabIndex={-1}
479
+ 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"
480
+ >
481
+ New
482
+ </button>
483
+ </div>
484
+ </div>
485
+ </div>
486
+ );
487
+ }
488
+
489
+ export default function NotesPage() {
490
+ const { app, route, databases, collectionItem } = useNotis();
491
+ const runtime = useNotisRuntime();
492
+ const { upsert } = useUpsertDocument();
493
+
494
+ const [activeTab, setActiveTab] = useState<TabKey>('gallery');
495
+ const [activeDateProperty, setActiveDateProperty] = useState('');
496
+ const [visibleMonth, setVisibleMonth] = useState(() => new Date());
497
+ const [creatingDocument, setCreatingDocument] = useState(false);
498
+ const [savingNoteId, setSavingNoteId] = useState<string | null>(null);
499
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
500
+ const [searchQuery, setSearchQuery] = useState('');
501
+
502
+ const noteDatabase = databases.find((d) => d.slug === NOTE_DATABASE_SLUG) ?? null;
503
+ const noteProperties = noteDatabase?.properties ?? [];
504
+ const titleProperty = noteProperties.find((p) => p.type === 'title');
505
+ const titlePropertyName = titleProperty?.name ?? 'Title';
506
+ const folderPropertyName = noteProperties.find((p) => p.type === 'relation')?.name ?? 'Folder';
507
+ const statusPropertyName =
508
+ noteProperties.find((p) => p.type === 'status' || p.name === 'Status')?.name ?? null;
509
+ const dateProperties = noteProperties.filter((p) => p.type === 'date');
510
+ const metadataProperties = noteProperties.filter((p) => p.name !== titlePropertyName);
511
+
512
+ const activeFolderId = collectionItem?.id ?? null;
513
+
514
+ const notesFilter = activeFolderId
515
+ ? {
516
+ filters: [
517
+ {
518
+ property: folderPropertyName,
519
+ operator: 'contains',
520
+ type: 'relation',
521
+ value: activeFolderId,
522
+ },
523
+ ],
524
+ }
525
+ : undefined;
526
+
527
+ const notesQuery = useDatabase(NOTE_DATABASE_SLUG, { filter: notesFilter, pageSize: 250 });
528
+ const foldersQuery = useDatabase(FOLDER_DATABASE_SLUG, { pageSize: 500 });
529
+
530
+ const { setLoading: setSearchLoading } = useTopBarSearch({
531
+ value: searchQuery,
532
+ onChange: setSearchQuery,
533
+ placeholder: 'Search notes…',
534
+ onSubmit: notesQuery.refetch,
535
+ });
536
+
537
+ useEffect(() => {
538
+ setSearchLoading(notesQuery.loading);
539
+ }, [notesQuery.loading, setSearchLoading]);
540
+
541
+ const trimmedQuery = searchQuery.trim().toLowerCase();
542
+ const allNotes = notesQuery.documents;
543
+ const notes = trimmedQuery
544
+ ? allNotes.filter((note) => {
545
+ if (getNoteTitle(note).toLowerCase().includes(trimmedQuery)) return true;
546
+ if (isPresentString(note.plainText) && note.plainText.toLowerCase().includes(trimmedQuery)) return true;
547
+ if (isPresentString(note.contentMarkdown) && note.contentMarkdown.toLowerCase().includes(trimmedQuery)) return true;
548
+ return false;
549
+ })
550
+ : allNotes;
551
+ const folders = foldersQuery.documents;
552
+ const folderOptions = buildFolderOptions(folders);
553
+ const folderNameById = new Map(folderOptions.map((f) => [f.id, f.title]));
554
+
555
+ const calendarPropertyName =
556
+ dateProperties.find((p) => p.name === activeDateProperty)?.name ?? dateProperties[0]?.name ?? '';
557
+
558
+ const notesByDay = new Map<string, DocumentRecord[]>();
559
+ for (const note of notes) {
560
+ const key = getDateKey(note.properties[calendarPropertyName]);
561
+ if (!key) continue;
562
+ const bucket = notesByDay.get(key) ?? [];
563
+ bucket.push(note);
564
+ notesByDay.set(key, bucket);
565
+ }
566
+ const scheduledNotesCount = notes.filter((n) =>
567
+ dateProperties.some((p) => Boolean(getDateKey(n.properties[p.name]))),
568
+ ).length;
569
+
570
+ const monthDays = buildCalendarDays(visibleMonth);
571
+ const isLoading = notesQuery.loading || foldersQuery.loading;
572
+ const currentFolderLabel = collectionItem?.title ?? 'All notes';
573
+ const topLevelError = errorMessage || notesQuery.error?.message || foldersQuery.error?.message;
574
+ const pageTitle = collectionItem?.title || route?.name || app?.name || 'Notes';
575
+ const pageIcon = collectionItem?.icon || route?.icon || null;
576
+
577
+ useEffect(() => {
578
+ if (!dateProperties.length) {
579
+ if (activeDateProperty) setActiveDateProperty('');
580
+ return;
581
+ }
582
+ if (!dateProperties.some((p) => p.name === activeDateProperty)) {
583
+ setActiveDateProperty(dateProperties[0].name);
584
+ }
585
+ }, [activeDateProperty, dateProperties]);
586
+
587
+ async function openNote(document: DocumentRecord) {
588
+ runtime?.navigate?.({ kind: 'document', documentId: document.id, title: document.title });
589
+ }
590
+
591
+ async function createDocument() {
592
+ setCreatingDocument(true);
593
+ setErrorMessage(null);
594
+ try {
595
+ const document = await upsert({
596
+ databaseSlug: NOTE_DATABASE_SLUG,
597
+ title: DEFAULT_NOTE_TITLE,
598
+ properties: activeFolderId ? { [folderPropertyName]: [activeFolderId] } : undefined,
599
+ });
600
+ runtime?.navigate?.({ kind: 'document', documentId: document.id, title: document.title });
601
+ } catch (error) {
602
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to create note');
603
+ } finally {
604
+ setCreatingDocument(false);
605
+ }
606
+ }
607
+
608
+ async function saveProperties(documentId: string, properties: Record<string, unknown>) {
609
+ setSavingNoteId(documentId);
610
+ setErrorMessage(null);
611
+ try {
612
+ await upsert({ databaseSlug: NOTE_DATABASE_SLUG, documentId, properties });
613
+ notesQuery.refetch();
614
+ } catch (error) {
615
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to save note');
616
+ } finally {
617
+ setSavingNoteId(null);
618
+ }
619
+ }
620
+
621
+ async function saveTitle(documentId: string, nextTitle: string) {
622
+ const normalized = nextTitle.trim() || DEFAULT_NOTE_TITLE;
623
+ setSavingNoteId(documentId);
624
+ setErrorMessage(null);
625
+ try {
626
+ await upsert({ databaseSlug: NOTE_DATABASE_SLUG, documentId, title: normalized });
627
+ notesQuery.refetch();
628
+ } catch (error) {
629
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to rename note');
630
+ } finally {
631
+ setSavingNoteId(null);
632
+ }
633
+ }
634
+
635
+ const rowRefs = useRef(new Map<string, HTMLTableRowElement>());
636
+ const multiSelect = useMultiSelect<DocumentRecord>({
637
+ items: notes,
638
+ getId: (note) => note.id,
639
+ bindKeyboardShortcuts: activeTab === 'table',
640
+ enableDragSelect: activeTab === 'table',
641
+ onHeadChange: (id) => {
642
+ if (!id) return;
643
+ rowRefs.current.get(id)?.scrollIntoView({ block: 'nearest' });
644
+ },
645
+ });
646
+
647
+ async function bulkClearFolder() {
648
+ const ids = multiSelect.getSelectedItems().map((n) => n.id);
649
+ if (ids.length === 0) return;
650
+ setErrorMessage(null);
651
+ try {
652
+ await Promise.all(
653
+ ids.map((documentId) =>
654
+ upsert({
655
+ databaseSlug: NOTE_DATABASE_SLUG,
656
+ documentId,
657
+ properties: { [folderPropertyName]: [] },
658
+ }),
659
+ ),
660
+ );
661
+ multiSelect.clear();
662
+ notesQuery.refetch();
663
+ } catch (error) {
664
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to update notes');
665
+ }
666
+ }
667
+
668
+ return (
669
+ <main className="flex min-h-screen flex-col bg-background">
670
+ <PageHeader
671
+ title={pageTitle}
672
+ icon={pageIcon}
673
+ activeTab={activeTab}
674
+ onTabChange={setActiveTab}
675
+ />
676
+
677
+ {topLevelError ? (
678
+ <div className="mx-6 mt-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
679
+ {topLevelError}
680
+ </div>
681
+ ) : null}
682
+
683
+ {isLoading ? <LoadingState /> : null}
684
+
685
+ {!isLoading && activeTab === 'gallery' ? (
686
+ <GalleryBody
687
+ notes={notes}
688
+ statusPropertyName={statusPropertyName}
689
+ onOpenNote={openNote}
690
+ onCreateDocument={createDocument}
691
+ creatingDocument={creatingDocument}
692
+ currentFolderLabel={currentFolderLabel}
693
+ hasCollectionItem={Boolean(activeFolderId)}
694
+ />
695
+ ) : null}
696
+
697
+ {!isLoading && activeTab === 'table' ? (
698
+ <TableBody
699
+ notes={notes}
700
+ metadataProperties={metadataProperties}
701
+ folderOptions={folderOptions}
702
+ folderNameById={folderNameById}
703
+ folderPropertyName={folderPropertyName}
704
+ savingNoteId={savingNoteId}
705
+ onOpen={openNote}
706
+ onSaveTitle={saveTitle}
707
+ onSaveProperties={saveProperties}
708
+ currentFolderLabel={currentFolderLabel}
709
+ hasCollectionItem={Boolean(activeFolderId)}
710
+ multiSelect={multiSelect}
711
+ rowRefs={rowRefs.current}
712
+ />
713
+ ) : null}
714
+
715
+ {!isLoading && activeTab === 'calendar' ? (
716
+ <CalendarBody
717
+ notes={notes}
718
+ monthDays={monthDays}
719
+ visibleMonth={visibleMonth}
720
+ setVisibleMonth={setVisibleMonth}
721
+ notesByDay={notesByDay}
722
+ statusPropertyName={statusPropertyName}
723
+ calendarPropertyName={calendarPropertyName}
724
+ dateProperties={dateProperties}
725
+ activeDateProperty={activeDateProperty}
726
+ setActiveDateProperty={setActiveDateProperty}
727
+ scheduledNotesCount={scheduledNotesCount}
728
+ onOpen={openNote}
729
+ />
730
+ ) : null}
731
+
732
+ {activeTab === 'table' ? (
733
+ <>
734
+ <MultiSelectDragOverlay rect={multiSelect.dragRect} />
735
+ <MultiSelectActionBar
736
+ selectedCount={multiSelect.selectedCount}
737
+ itemLabel={{ singular: 'note', plural: 'notes' }}
738
+ actions={[
739
+ {
740
+ id: 'clear-folder',
741
+ label: 'Move out of folder',
742
+ shortcut: 'M',
743
+ icon: <FolderMinus className="h-3.5 w-3.5" />,
744
+ onRun: bulkClearFolder,
745
+ },
746
+ ]}
747
+ />
748
+ </>
749
+ ) : null}
750
+ </main>
751
+ );
752
+ }
753
+
754
+ /* -------------------------------------------------------------------------- */
755
+ /* Gallery view */
756
+ /* -------------------------------------------------------------------------- */
757
+
758
+ function GalleryBody({
759
+ notes,
760
+ statusPropertyName,
761
+ onOpenNote,
762
+ onCreateDocument,
763
+ creatingDocument,
764
+ currentFolderLabel,
765
+ hasCollectionItem,
766
+ }: {
767
+ notes: DocumentRecord[];
768
+ statusPropertyName: string | null;
769
+ onOpenNote: (doc: DocumentRecord) => void;
770
+ onCreateDocument: () => Promise<void>;
771
+ creatingDocument: boolean;
772
+ currentFolderLabel: string;
773
+ hasCollectionItem: boolean;
774
+ }) {
775
+ if (!notes.length) {
776
+ return (
777
+ <EmptyState
778
+ icon={LayoutGrid}
779
+ title={hasCollectionItem ? 'No notes in this folder' : 'No notes yet'}
780
+ description={
781
+ hasCollectionItem
782
+ ? `Create the first note in ${currentFolderLabel}.`
783
+ : 'Folders live in the sidebar. Create a note here to get started.'
784
+ }
785
+ action={
786
+ <Button
787
+ size="sm"
788
+ onClick={() => void onCreateDocument()}
789
+ disabled={creatingDocument}
790
+ className="gap-1.5"
791
+ >
792
+ <Plus className="h-3.5 w-3.5" />
793
+ {creatingDocument ? 'Creating…' : 'New note'}
794
+ </Button>
795
+ }
796
+ />
797
+ );
798
+ }
799
+
800
+ return (
801
+ <div className="grid grid-cols-1 gap-4 px-4 py-4 sm:grid-cols-2 sm:px-6 lg:grid-cols-3 xl:grid-cols-4">
802
+ {notes.map((note) => (
803
+ <NoteCard
804
+ key={note.id}
805
+ note={note}
806
+ statusPropertyName={statusPropertyName}
807
+ onOpen={() => onOpenNote(note)}
808
+ />
809
+ ))}
810
+ </div>
811
+ );
812
+ }
813
+
814
+ function NoteCard({
815
+ note,
816
+ statusPropertyName,
817
+ onOpen,
818
+ }: {
819
+ note: DocumentRecord;
820
+ statusPropertyName: string | null;
821
+ onOpen: () => void;
822
+ }) {
823
+ const status = statusPropertyName ? getStatusLabel(note.properties[statusPropertyName]) : null;
824
+ const coverUrl = getCoverUrl(note);
825
+ const title = getNoteTitle(note);
826
+ const previewText = getNotePreviewText(note);
827
+
828
+ return (
829
+ <button
830
+ type="button"
831
+ onClick={onOpen}
832
+ className={cn(
833
+ 'group flex h-full w-full flex-col overflow-hidden rounded-xl border border-border bg-card text-left',
834
+ 'transition-colors hover:border-foreground/20 hover:shadow-sm',
835
+ )}
836
+ >
837
+ <div className="relative aspect-[16/10] w-full overflow-hidden">
838
+ {coverUrl ? (
839
+ // eslint-disable-next-line @next/next/no-img-element
840
+ <img src={coverUrl} alt="" className="h-full w-full object-cover" />
841
+ ) : (
842
+ <div className="flex h-full w-full bg-card px-5 py-5 text-foreground sm:px-6">
843
+ <p className="line-clamp-5 text-xl font-semibold leading-snug text-foreground/75 sm:text-2xl">
844
+ {previewText}
845
+ </p>
846
+ </div>
847
+ )}
848
+ {status ? (
849
+ <div className="absolute right-2 top-2">
850
+ <StatusPill status={status} />
851
+ </div>
852
+ ) : null}
853
+ </div>
854
+ <div className="flex w-full items-center gap-1.5 border-t border-border px-3 py-2">
855
+ <NoteIcon icon={note.icon} className="shrink-0 text-muted-foreground" />
856
+ <span className="line-clamp-1 text-[12.5px] text-foreground">
857
+ {title}
858
+ </span>
859
+ </div>
860
+ </button>
861
+ );
862
+ }
863
+
864
+ /* -------------------------------------------------------------------------- */
865
+ /* Table view */
866
+ /* -------------------------------------------------------------------------- */
867
+
868
+ function TableBody({
869
+ notes,
870
+ metadataProperties,
871
+ folderOptions,
872
+ folderNameById,
873
+ folderPropertyName,
874
+ savingNoteId,
875
+ onOpen,
876
+ onSaveTitle,
877
+ onSaveProperties,
878
+ currentFolderLabel,
879
+ hasCollectionItem,
880
+ multiSelect,
881
+ rowRefs,
882
+ }: {
883
+ notes: DocumentRecord[];
884
+ metadataProperties: DatabaseProperty[];
885
+ folderOptions: FolderOption[];
886
+ folderNameById: Map<string, string>;
887
+ folderPropertyName: string;
888
+ savingNoteId: string | null;
889
+ onOpen: (doc: DocumentRecord) => void;
890
+ onSaveTitle: (id: string, title: string) => void;
891
+ onSaveProperties: (id: string, props: Record<string, unknown>) => void;
892
+ currentFolderLabel: string;
893
+ hasCollectionItem: boolean;
894
+ multiSelect: MultiSelectController<DocumentRecord>;
895
+ rowRefs: Map<string, HTMLTableRowElement>;
896
+ }) {
897
+ const columns = metadataProperties.slice(0, 6);
898
+ const allSelected =
899
+ notes.length > 0 && notes.every((note) => multiSelect.isSelected(note.id));
900
+ const handleSelectAllToggle = () => {
901
+ if (allSelected) {
902
+ multiSelect.clear();
903
+ return;
904
+ }
905
+ multiSelect.select(notes.map((note) => note.id));
906
+ };
907
+
908
+ return (
909
+ <div className="flex flex-1 flex-col gap-4 px-6 py-5">
910
+ <div className="flex flex-wrap items-center gap-2">
911
+ <FilterChip icon={Settings2} label="Status is" value="Drafting, Review" />
912
+ <FilterChip icon={CalendarDays} label="Due" value="this week" />
913
+ <FilterChip icon={Rows3} label="Sort by" value="Updated ↓" />
914
+ <button
915
+ type="button"
916
+ className="inline-flex items-center gap-1.5 rounded-full border border-dashed border-border px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground"
917
+ >
918
+ <Plus className="h-3 w-3" />
919
+ Add filter
920
+ </button>
921
+ <div className="flex-1" />
922
+ <Eyebrow>Hide · Tags, Author</Eyebrow>
923
+ </div>
924
+
925
+ {!notes.length ? (
926
+ <EmptyState
927
+ icon={Table2}
928
+ title={hasCollectionItem ? 'No rows in this folder' : 'No rows yet'}
929
+ description={
930
+ hasCollectionItem
931
+ ? `Create a note in ${currentFolderLabel} to start editing metadata here.`
932
+ : 'Notes become rows here. Create one to begin editing metadata inline.'
933
+ }
934
+ />
935
+ ) : (
936
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
937
+ <div {...multiSelect.getContainerProps()} className="overflow-x-auto">
938
+ <table className="w-full min-w-[1040px] border-collapse text-[13px]">
939
+ <thead>
940
+ <tr className="border-b border-border bg-muted/40">
941
+ <th className="w-10 px-3 py-2.5">
942
+ <MultiSelectCheckbox
943
+ isSelected={allSelected}
944
+ onClick={(e) => {
945
+ e.stopPropagation();
946
+ handleSelectAllToggle();
947
+ }}
948
+ alwaysVisible
949
+ ariaLabel={allSelected ? 'Deselect all notes' : 'Select all notes'}
950
+ />
951
+ </th>
952
+ <th className="px-3 py-2.5 text-left">
953
+ <div className="flex items-center gap-1.5">
954
+ <FileText className="h-3 w-3 text-muted-foreground" />
955
+ <Eyebrow>Note</Eyebrow>
956
+ </div>
957
+ </th>
958
+ {columns.map((p) => (
959
+ <th key={p.name} className="border-l border-border px-3 py-2.5 text-left">
960
+ <Eyebrow>{p.name}</Eyebrow>
961
+ </th>
962
+ ))}
963
+ <th className="w-10 border-l border-border" />
964
+ </tr>
965
+ </thead>
966
+ <tbody>
967
+ {notes.map((note) => (
968
+ <TableRow
969
+ key={note.id}
970
+ note={note}
971
+ columns={columns}
972
+ folderOptions={folderOptions}
973
+ folderNameById={folderNameById}
974
+ folderPropertyName={folderPropertyName}
975
+ saving={savingNoteId === note.id}
976
+ onOpen={() => onOpen(note)}
977
+ onSaveTitle={(title) => onSaveTitle(note.id, title)}
978
+ onSaveProperties={(props) => onSaveProperties(note.id, props)}
979
+ isSelected={multiSelect.isSelected(note.id)}
980
+ onCheckboxClick={multiSelect.onCheckboxClick(note.id)}
981
+ onRowMouseDown={multiSelect.onRowMouseDown(note.id)}
982
+ rowProps={multiSelect.getRowProps(note.id)}
983
+ rowRef={(node) => {
984
+ if (node) {
985
+ rowRefs.set(note.id, node);
986
+ } else {
987
+ rowRefs.delete(note.id);
988
+ }
989
+ }}
990
+ />
991
+ ))}
992
+ </tbody>
993
+ </table>
994
+ </div>
995
+ </div>
996
+ )}
997
+
998
+ <div className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
999
+ <div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-muted">
1000
+ <Sparkles className="h-3.5 w-3.5 text-foreground" />
1001
+ </div>
1002
+ <div className="min-w-0 flex-1">
1003
+ <p className="text-[13px] font-semibold text-foreground">Need a different view?</p>
1004
+ <p className="text-[12px] text-muted-foreground">
1005
+ Ask Notis to add a column, group by folder, hide shipped rows, or sort differently. No settings maze.
1006
+ </p>
1007
+ </div>
1008
+ <div className="flex flex-wrap gap-2">
1009
+ <AgentHintChip>group by Folder</AgentHintChip>
1010
+ <AgentHintChip>hide Updated</AgentHintChip>
1011
+ <AgentHintChip>sort by Words</AgentHintChip>
1012
+ </div>
1013
+ </div>
1014
+ </div>
1015
+ );
1016
+ }
1017
+
1018
+ function FilterChip({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
1019
+ return (
1020
+ <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px]">
1021
+ <Icon className="h-3 w-3 text-muted-foreground" />
1022
+ <span className="text-muted-foreground">{label}</span>
1023
+ <span className="font-semibold text-foreground">{value}</span>
1024
+ </span>
1025
+ );
1026
+ }
1027
+
1028
+ function AgentHintChip({ children }: { children: React.ReactNode }) {
1029
+ return (
1030
+ <span className="rounded-full bg-muted px-2.5 py-1 font-mono text-[10px] text-foreground/80">
1031
+ {children}
1032
+ </span>
1033
+ );
1034
+ }
1035
+
1036
+ function TableRow({
1037
+ note,
1038
+ columns,
1039
+ folderOptions,
1040
+ folderNameById,
1041
+ folderPropertyName,
1042
+ saving,
1043
+ onOpen,
1044
+ onSaveTitle,
1045
+ onSaveProperties,
1046
+ isSelected,
1047
+ onCheckboxClick,
1048
+ onRowMouseDown,
1049
+ rowProps,
1050
+ rowRef,
1051
+ }: {
1052
+ note: DocumentRecord;
1053
+ columns: DatabaseProperty[];
1054
+ folderOptions: FolderOption[];
1055
+ folderNameById: Map<string, string>;
1056
+ folderPropertyName: string;
1057
+ saving: boolean;
1058
+ onOpen: () => void;
1059
+ onSaveTitle: (title: string) => void;
1060
+ onSaveProperties: (props: Record<string, unknown>) => void;
1061
+ isSelected: boolean;
1062
+ onCheckboxClick: (event: React.MouseEvent) => void;
1063
+ onRowMouseDown: (event: React.MouseEvent) => void;
1064
+ rowProps: Record<string, string>;
1065
+ rowRef: (node: HTMLTableRowElement | null) => void;
1066
+ }) {
1067
+ return (
1068
+ <tr
1069
+ {...rowProps}
1070
+ ref={rowRef}
1071
+ className={cn(
1072
+ 'group border-b border-border last:border-b-0 transition-colors',
1073
+ saving ? 'bg-muted/20' : 'hover:bg-muted/30',
1074
+ isSelected && 'bg-primary/5 hover:bg-primary/10',
1075
+ )}
1076
+ onClick={onOpen}
1077
+ onMouseDown={onRowMouseDown}
1078
+ onKeyDown={(e) => {
1079
+ if (e.key === 'Enter') {
1080
+ e.preventDefault();
1081
+ onOpen();
1082
+ }
1083
+ }}
1084
+ tabIndex={0}
1085
+ >
1086
+ <td className="px-3 py-2">
1087
+ <MultiSelectCheckbox
1088
+ isSelected={isSelected}
1089
+ onClick={onCheckboxClick}
1090
+ alwaysVisible={isSelected}
1091
+ className={cn(
1092
+ 'transition-opacity',
1093
+ !isSelected && 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
1094
+ )}
1095
+ />
1096
+ </td>
1097
+ <td className="px-3 py-2">
1098
+ <div className="flex items-center gap-2">
1099
+ <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md bg-muted">
1100
+ <FileText className="h-3 w-3 text-muted-foreground" />
1101
+ </div>
1102
+ <input
1103
+ type="text"
1104
+ defaultValue={getNoteTitle(note)}
1105
+ key={`${note.id}:title:${getNoteTitle(note)}`}
1106
+ onClick={(e) => e.stopPropagation()}
1107
+ onKeyDown={(e) => {
1108
+ if (e.key === 'Enter') e.currentTarget.blur();
1109
+ }}
1110
+ onBlur={(e) => onSaveTitle(e.target.value)}
1111
+ 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"
1112
+ />
1113
+ <ArrowUpRight className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-60" />
1114
+ </div>
1115
+ </td>
1116
+ {columns.map((prop) => (
1117
+ <td key={prop.name} className="border-l border-border px-3 py-2 align-middle">
1118
+ <TableCell
1119
+ note={note}
1120
+ property={prop}
1121
+ folderOptions={folderOptions}
1122
+ folderNameById={folderNameById}
1123
+ folderPropertyName={folderPropertyName}
1124
+ onSaveProperties={onSaveProperties}
1125
+ />
1126
+ </td>
1127
+ ))}
1128
+ <td className="w-10 border-l border-border" />
1129
+ </tr>
1130
+ );
1131
+ }
1132
+
1133
+ function TableCell({
1134
+ note,
1135
+ property,
1136
+ folderOptions,
1137
+ folderNameById,
1138
+ folderPropertyName,
1139
+ onSaveProperties,
1140
+ }: {
1141
+ note: DocumentRecord;
1142
+ property: DatabaseProperty;
1143
+ folderOptions: FolderOption[];
1144
+ folderNameById: Map<string, string>;
1145
+ folderPropertyName: string;
1146
+ onSaveProperties: (props: Record<string, unknown>) => void;
1147
+ }) {
1148
+ const value = note.properties[property.name];
1149
+ const selectClass =
1150
+ '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';
1151
+
1152
+ if (property.type === 'status' || property.type === 'select') {
1153
+ const label = isPresentString(value) ? value : '';
1154
+ return (
1155
+ <div className="relative inline-flex items-center" onClick={(e) => e.stopPropagation()}>
1156
+ {label ? (
1157
+ <StatusPill status={label} />
1158
+ ) : (
1159
+ <span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">None</span>
1160
+ )}
1161
+ <select
1162
+ aria-label={property.name}
1163
+ className="absolute inset-0 cursor-pointer opacity-0"
1164
+ value={label}
1165
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.value || null })}
1166
+ >
1167
+ <option value="">None</option>
1168
+ {(property.options ?? []).map((opt) => (
1169
+ <option key={opt.id ?? opt.name} value={opt.name}>
1170
+ {opt.name}
1171
+ </option>
1172
+ ))}
1173
+ </select>
1174
+ </div>
1175
+ );
1176
+ }
1177
+
1178
+ if (property.type === 'date') {
1179
+ return (
1180
+ <input
1181
+ type="date"
1182
+ value={getDateInputValue(value)}
1183
+ onClick={(e) => e.stopPropagation()}
1184
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.value || null })}
1185
+ className={cn(selectClass, 'text-[12px]')}
1186
+ />
1187
+ );
1188
+ }
1189
+
1190
+ if (property.type === 'checkbox') {
1191
+ return (
1192
+ <label
1193
+ className="inline-flex items-center"
1194
+ onClick={(e) => e.stopPropagation()}
1195
+ >
1196
+ <input
1197
+ type="checkbox"
1198
+ checked={Boolean(value)}
1199
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.checked })}
1200
+ className="h-3.5 w-3.5 rounded border-border"
1201
+ />
1202
+ </label>
1203
+ );
1204
+ }
1205
+
1206
+ if (property.type === 'relation' && property.name === folderPropertyName) {
1207
+ const currentId = getRelationIds(value)[0] ?? '';
1208
+ return (
1209
+ <select
1210
+ className={cn(selectClass, 'text-[12px]')}
1211
+ value={currentId}
1212
+ onClick={(e) => e.stopPropagation()}
1213
+ onChange={(e) => onSaveProperties({ [property.name]: e.target.value ? [e.target.value] : [] })}
1214
+ >
1215
+ <option value="">No folder</option>
1216
+ {folderOptions.map((f) => (
1217
+ <option key={f.id} value={f.id}>
1218
+ {f.pathLabel}
1219
+ </option>
1220
+ ))}
1221
+ </select>
1222
+ );
1223
+ }
1224
+
1225
+ if (property.type === 'number') {
1226
+ return (
1227
+ <input
1228
+ type="number"
1229
+ defaultValue={typeof value === 'number' ? String(value) : ''}
1230
+ inputMode="decimal"
1231
+ key={`${note.id}:${property.name}:${String(value ?? '')}`}
1232
+ onClick={(e) => e.stopPropagation()}
1233
+ onKeyDown={(e) => {
1234
+ if (e.key === 'Enter') e.currentTarget.blur();
1235
+ }}
1236
+ onBlur={(e) => {
1237
+ const v = e.target.value.trim();
1238
+ onSaveProperties({ [property.name]: v ? Number(v) : null });
1239
+ }}
1240
+ className={cn(selectClass, 'font-mono text-[12px]')}
1241
+ />
1242
+ );
1243
+ }
1244
+
1245
+ if (property.type === 'rich_text') {
1246
+ return (
1247
+ <input
1248
+ type="text"
1249
+ defaultValue={isPresentString(value) ? value : ''}
1250
+ key={`${note.id}:${property.name}:${String(value ?? '')}`}
1251
+ onClick={(e) => e.stopPropagation()}
1252
+ onKeyDown={(e) => {
1253
+ if (e.key === 'Enter') e.currentTarget.blur();
1254
+ }}
1255
+ onBlur={(e) => {
1256
+ const v = e.target.value.trim();
1257
+ onSaveProperties({ [property.name]: v || null });
1258
+ }}
1259
+ className={cn(selectClass, 'text-[12px]')}
1260
+ />
1261
+ );
1262
+ }
1263
+
1264
+ return (
1265
+ <span className="px-1.5 text-[12px] text-muted-foreground">
1266
+ {formatPropertyValue(value, property, folderNameById)}
1267
+ </span>
1268
+ );
1269
+ }
1270
+
1271
+ /* -------------------------------------------------------------------------- */
1272
+ /* Calendar view */
1273
+ /* -------------------------------------------------------------------------- */
1274
+
1275
+ function CalendarBody({
1276
+ notes,
1277
+ monthDays,
1278
+ visibleMonth,
1279
+ setVisibleMonth,
1280
+ notesByDay,
1281
+ statusPropertyName,
1282
+ calendarPropertyName,
1283
+ dateProperties,
1284
+ activeDateProperty,
1285
+ setActiveDateProperty,
1286
+ scheduledNotesCount,
1287
+ onOpen,
1288
+ }: {
1289
+ notes: DocumentRecord[];
1290
+ monthDays: Date[];
1291
+ visibleMonth: Date;
1292
+ setVisibleMonth: (d: Date) => void;
1293
+ notesByDay: Map<string, DocumentRecord[]>;
1294
+ statusPropertyName: string | null;
1295
+ calendarPropertyName: string;
1296
+ dateProperties: DatabaseProperty[];
1297
+ activeDateProperty: string;
1298
+ setActiveDateProperty: (v: string) => void;
1299
+ scheduledNotesCount: number;
1300
+ onOpen: (doc: DocumentRecord) => void;
1301
+ }) {
1302
+ return (
1303
+ <div className="flex flex-1 flex-col gap-4 px-6 py-5">
1304
+ <div className="flex flex-wrap items-center gap-2">
1305
+ <div className="inline-flex items-center gap-0 rounded-lg border border-border bg-background p-0.5">
1306
+ <Button
1307
+ variant="ghost"
1308
+ size="icon"
1309
+ className="h-7 w-7"
1310
+ onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1))}
1311
+ >
1312
+ <ChevronLeft className="h-3.5 w-3.5" />
1313
+ </Button>
1314
+ <div className="px-2 text-[13px] font-semibold tracking-tight text-foreground">
1315
+ {formatMonthLabel(visibleMonth)}
1316
+ </div>
1317
+ <Button
1318
+ variant="ghost"
1319
+ size="icon"
1320
+ className="h-7 w-7"
1321
+ onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1))}
1322
+ >
1323
+ <ChevronRight className="h-3.5 w-3.5" />
1324
+ </Button>
1325
+ </div>
1326
+ <Button variant="outline" size="sm" className="h-8 text-[12px]" onClick={() => setVisibleMonth(new Date())}>
1327
+ Today
1328
+ </Button>
1329
+
1330
+ <div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px]">
1331
+ <CalendarDays className="h-3 w-3 text-muted-foreground" />
1332
+ <span className="text-muted-foreground">Date field</span>
1333
+ <select
1334
+ className="appearance-none bg-transparent pr-3 font-semibold text-foreground outline-none"
1335
+ value={calendarPropertyName}
1336
+ onChange={(e) => setActiveDateProperty(e.target.value)}
1337
+ disabled={!dateProperties.length}
1338
+ >
1339
+ {dateProperties.length ? (
1340
+ dateProperties.map((p) => (
1341
+ <option key={p.name} value={p.name}>
1342
+ {p.name}
1343
+ </option>
1344
+ ))
1345
+ ) : (
1346
+ <option value="">—</option>
1347
+ )}
1348
+ </select>
1349
+ <ChevronDown className="h-3 w-3 text-muted-foreground" />
1350
+ </div>
1351
+
1352
+ <div className="flex-1" />
1353
+ <Eyebrow>{pluralize(scheduledNotesCount, 'note')} scheduled</Eyebrow>
1354
+ </div>
1355
+
1356
+ {!dateProperties.length ? (
1357
+ <EmptyState
1358
+ icon={CalendarDays}
1359
+ title="No date property yet"
1360
+ description="Add a date field to the notes database and this board will start plotting notes automatically."
1361
+ />
1362
+ ) : (
1363
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
1364
+ <div className="grid grid-cols-7 border-b border-border bg-muted/40">
1365
+ {WEEKDAY_LABELS.map((label, i) => (
1366
+ <div
1367
+ key={label}
1368
+ className={cn(
1369
+ 'px-3 py-2 font-mono text-[10px] font-medium uppercase tracking-[0.14em]',
1370
+ i >= 5 ? 'text-muted-foreground/60' : 'text-muted-foreground',
1371
+ )}
1372
+ >
1373
+ {label}
1374
+ </div>
1375
+ ))}
1376
+ </div>
1377
+ <div className="grid grid-cols-7">
1378
+ {monthDays.map((day) => {
1379
+ const key = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`;
1380
+ const dayNotes = notesByDay.get(key) ?? [];
1381
+ const inMonth = isSameMonth(day, visibleMonth);
1382
+ const today = isToday(day);
1383
+ return (
1384
+ <div
1385
+ key={key}
1386
+ className={cn(
1387
+ 'flex min-h-[110px] flex-col gap-1.5 border-b border-r border-border px-2 py-2',
1388
+ !inMonth && 'bg-muted/30',
1389
+ )}
1390
+ >
1391
+ <div className="flex items-center justify-between">
1392
+ {today ? (
1393
+ <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-foreground text-[11px] font-semibold text-background">
1394
+ {day.getDate()}
1395
+ </span>
1396
+ ) : (
1397
+ <span
1398
+ className={cn(
1399
+ 'text-[12px] font-medium',
1400
+ inMonth ? 'text-foreground/80' : 'text-muted-foreground/50',
1401
+ )}
1402
+ >
1403
+ {day.getDate()}
1404
+ </span>
1405
+ )}
1406
+ {today ? <Eyebrow className="text-foreground">Today</Eyebrow> : null}
1407
+ </div>
1408
+ <div className="flex flex-col gap-1">
1409
+ {dayNotes.slice(0, 3).map((note) => {
1410
+ const status = statusPropertyName
1411
+ ? getStatusLabel(note.properties[statusPropertyName])
1412
+ : null;
1413
+ const tone = getStatusTone(status);
1414
+ return (
1415
+ <button
1416
+ key={note.id}
1417
+ type="button"
1418
+ onClick={() => onOpen(note)}
1419
+ className={cn(
1420
+ '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',
1421
+ statusBarClasses[tone],
1422
+ )}
1423
+ >
1424
+ {getNoteTitle(note)}
1425
+ </button>
1426
+ );
1427
+ })}
1428
+ {dayNotes.length > 3 ? (
1429
+ <span className="px-1 text-[10px] text-muted-foreground">+{dayNotes.length - 3} more</span>
1430
+ ) : null}
1431
+ </div>
1432
+ </div>
1433
+ );
1434
+ })}
1435
+ </div>
1436
+ </div>
1437
+ )}
1438
+
1439
+ <div className="flex flex-wrap items-center gap-4 rounded-lg border border-border bg-card px-4 py-3">
1440
+ <Eyebrow>Legend</Eyebrow>
1441
+ <LegendItem color="bg-amber-500" label="Drafting" />
1442
+ <LegendItem color="bg-blue-500" label="Review" />
1443
+ <LegendItem color="bg-emerald-500" label="Shipped" />
1444
+ <LegendItem color="bg-stone-400" label="Idea" />
1445
+ <div className="flex-1" />
1446
+ <div className="flex flex-wrap items-center gap-2">
1447
+ <Sparkles className="h-3.5 w-3.5 text-foreground" />
1448
+ <span className="text-[12px] text-muted-foreground">Ask Notis to</span>
1449
+ <AgentHintChip>use Publish date</AgentHintChip>
1450
+ <AgentHintChip>color by Folder</AgentHintChip>
1451
+ <AgentHintChip>switch to week view</AgentHintChip>
1452
+ </div>
1453
+ </div>
1454
+ </div>
1455
+ );
1456
+ }
1457
+
1458
+ function LegendItem({ color, label }: { color: string; label: string }) {
1459
+ return (
1460
+ <div className="flex items-center gap-1.5">
1461
+ <span className={cn('h-0.5 w-3 rounded', color)} />
1462
+ <span className="text-[11px] font-medium text-foreground/80">{label}</span>
1463
+ </div>
1464
+ );
1465
+ }