@notis_ai/cli 0.2.1 → 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.
- package/README.md +98 -163
- package/dist/scaffolds/notes/app/globals.css +3 -0
- package/dist/scaffolds/notes/app/layout.tsx +6 -0
- package/dist/scaffolds/notes/app/page.tsx +1465 -0
- package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notes/components.json +20 -0
- package/dist/scaffolds/notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notes/notis.config.ts +31 -0
- package/dist/scaffolds/notes/package.json +31 -0
- package/dist/scaffolds/notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notes/tailwind.config.ts +58 -0
- package/dist/scaffolds/notes/tsconfig.json +22 -0
- package/dist/scaffolds/notes/vite.config.ts +10 -0
- package/dist/scaffolds.json +13 -0
- package/package.json +7 -3
- package/skills/notis-apps/SKILL.md +162 -0
- package/skills/notis-apps/cli.md +208 -0
- package/skills/notis-cli/SKILL.md +258 -0
- package/skills/notis-query/cli.md +40 -0
- package/src/cli.js +1 -1
- package/src/command-specs/apps.js +211 -46
- package/src/command-specs/helpers.js +0 -60
- package/src/command-specs/index.js +0 -6
- package/src/command-specs/meta.js +7 -6
- package/src/command-specs/tools.js +1 -33
- package/src/runtime/app-dev-server.js +32 -4
- package/src/runtime/app-platform.js +404 -24
- package/src/runtime/profiles.js +2 -2
- package/src/runtime/transport.js +2 -2
- package/template/.harness/index.html.tmpl +1 -50
- package/template/app/page.tsx +41 -6
- package/template/metadata/cover.png +0 -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 +10 -6
- package/template/package.json +2 -2
- package/template/packages/{notis-sdk → sdk}/package.json +6 -0
- package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectActionBar.tsx +2 -1
- package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectCheckbox.tsx +2 -2
- package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectDragOverlay.tsx +2 -2
- package/template/packages/{notis-sdk → sdk}/src/config.ts +1 -1
- package/template/packages/{notis-sdk → sdk}/src/hooks/useDatabase.ts +1 -13
- package/template/packages/{notis-sdk → sdk}/src/hooks/useMultiSelect.ts +4 -3
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +4 -3
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +3 -3
- package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
- package/template/packages/{notis-sdk → sdk}/src/hooks/useUpsertDocument.ts +0 -8
- package/template/packages/{notis-sdk → sdk}/src/index.ts +2 -15
- package/template/packages/{notis-sdk → sdk}/src/provider.tsx +1 -1
- package/template/packages/{notis-sdk → sdk}/src/runtime.ts +5 -26
- package/src/command-specs/auth.js +0 -178
- package/src/command-specs/db.js +0 -163
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTopBarSearch.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/styles.css +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/vite.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
|
@@ -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
|
+
}
|