@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.
- package/README.md +334 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_boundary_rules.json +50 -0
- package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
- package/dist/scaffolds/notis-database/app/globals.css +44 -0
- package/dist/scaffolds/notis-database/app/layout.tsx +6 -0
- package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
- package/dist/scaffolds/notis-database/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-database/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-database/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
- package/dist/scaffolds/notis-database/components.json +20 -0
- package/dist/scaffolds/notis-database/index.html +12 -0
- package/dist/scaffolds/notis-database/lib/types.ts +134 -0
- package/dist/scaffolds/notis-database/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-database/notis.config.ts +26 -0
- package/dist/scaffolds/notis-database/package.json +31 -0
- package/dist/scaffolds/notis-database/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
- package/dist/scaffolds/notis-database/src/mock-runtime.ts +558 -0
- package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
- package/dist/scaffolds/notis-database/tsconfig.json +23 -0
- package/dist/scaffolds/notis-database/vite.config.ts +22 -0
- package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
- package/dist/scaffolds/notis-journal/app/globals.css +37 -0
- package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
- package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
- package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
- package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
- package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
- package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-journal/components.json +20 -0
- package/dist/scaffolds/notis-journal/index.html +12 -0
- package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
- package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
- package/dist/scaffolds/notis-journal/package.json +34 -0
- package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
- package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
- package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
- package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
- package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
- package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
- package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
- package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
- package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
- package/dist/scaffolds/notis-notes/app/globals.css +3 -0
- package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
- package/dist/scaffolds/notis-notes/app/page.tsx +1950 -0
- package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-notes/components.json +20 -0
- package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/notis.config.ts +35 -0
- package/dist/scaffolds/notis-notes/package.json +31 -0
- package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-notes/tailwind.config.ts +58 -0
- package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
- package/dist/scaffolds/notis-notes/vite.config.ts +10 -0
- package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
- package/dist/scaffolds/notis-random/README.md +33 -0
- package/dist/scaffolds/notis-random/app/globals.css +11 -0
- package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
- package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
- package/dist/scaffolds/notis-random/app/page.tsx +222 -0
- package/dist/scaffolds/notis-random/index.html +12 -0
- package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
- package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
- package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
- package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-random/notis.config.ts +42 -0
- package/dist/scaffolds/notis-random/package.json +32 -0
- package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
- package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
- package/dist/scaffolds/notis-random/src/mock-runtime.ts +129 -0
- package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
- package/dist/scaffolds/notis-random/tsconfig.json +23 -0
- package/dist/scaffolds/notis-random/vite.config.ts +11 -0
- package/dist/scaffolds.json +47 -0
- package/package.json +45 -0
- package/skills/notis-apps/SKILL.md +219 -0
- package/skills/notis-apps/cli.md +244 -0
- package/skills/notis-cli/SKILL.md +269 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/cli.js +175 -0
- package/src/command-specs/apps.js +1939 -0
- package/src/command-specs/helpers.js +186 -0
- package/src/command-specs/index.js +14 -0
- package/src/command-specs/meta.js +161 -0
- package/src/command-specs/tools.js +499 -0
- package/src/runtime/agent-browser.js +464 -0
- package/src/runtime/app-boundary-validator.js +183 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-dev-server.js +712 -0
- package/src/runtime/app-dev-sessions.js +251 -0
- package/src/runtime/app-platform.js +1500 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/desktop-auth.js +93 -0
- package/src/runtime/errors.js +55 -0
- package/src/runtime/help.js +60 -0
- package/src/runtime/output.js +180 -0
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +261 -0
- package/src/runtime/store-screenshot.js +138 -0
- package/src/runtime/transport.js +347 -0
- package/template/.harness/index.html.tmpl +333 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +6 -0
- package/template/app/page.tsx +60 -0
- package/template/components/ui/badge.tsx +28 -0
- package/template/components/ui/button.tsx +53 -0
- package/template/components/ui/card.tsx +56 -0
- package/template/components.json +20 -0
- package/template/lib/utils.ts +6 -0
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
- package/template/notis.config.ts +36 -0
- package/template/package-lock.json +4137 -0
- package/template/package.json +32 -0
- package/template/packages/sdk/package.json +36 -0
- package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
- package/template/packages/sdk/src/components/Markdown.tsx +60 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/config.ts +145 -0
- package/template/packages/sdk/src/documents.ts +229 -0
- package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
- package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
- package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
- package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/sdk/src/hooks/useTool.ts +64 -0
- package/template/packages/sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/template/packages/sdk/src/index.ts +83 -0
- package/template/packages/sdk/src/provider.tsx +43 -0
- package/template/packages/sdk/src/runtime.ts +220 -0
- package/template/packages/sdk/src/styles.css +186 -0
- package/template/packages/sdk/src/ui.ts +15 -0
- package/template/packages/sdk/src/vite.ts +56 -0
- package/template/packages/sdk/tsconfig.json +15 -0
- package/template/postcss.config.mjs +8 -0
- package/template/tailwind.config.ts +58 -0
- package/template/tsconfig.json +22 -0
- 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
|
+
}
|