@notis_ai/cli 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -18
- package/config/notis_app_boundary_rules.json +50 -0
- package/dist/scaffolds/notis-database/app/globals.css +44 -0
- package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
- package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
- package/dist/scaffolds/notis-database/index.html +12 -0
- package/dist/scaffolds/notis-database/lib/types.ts +134 -0
- package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-database/notis.config.ts +27 -0
- package/dist/scaffolds/notis-database/package.json +31 -0
- package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
- package/dist/scaffolds/notis-database/src/mock-runtime.ts +557 -0
- package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
- package/dist/scaffolds/{notes → notis-database}/tsconfig.json +2 -1
- package/dist/scaffolds/notis-database/vite.config.ts +22 -0
- package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
- package/dist/scaffolds/{notes → notis-notes}/app/page.tsx +783 -80
- package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-notes/components.json +20 -0
- package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/{notes → notis-notes}/notis.config.ts +12 -7
- package/dist/scaffolds/{notes → notis-notes}/package.json +2 -2
- package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
- package/dist/scaffolds/notis-random/README.md +33 -0
- package/dist/scaffolds/notis-random/app/globals.css +11 -0
- package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
- package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
- package/dist/scaffolds/notis-random/app/page.tsx +222 -0
- package/dist/scaffolds/notis-random/index.html +12 -0
- package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
- package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
- package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
- package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
- package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-random/notis.config.ts +43 -0
- package/dist/scaffolds/notis-random/package.json +32 -0
- package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
- package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
- package/dist/scaffolds/notis-random/src/mock-runtime.ts +128 -0
- package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
- package/dist/scaffolds/notis-random/tsconfig.json +23 -0
- package/dist/scaffolds/notis-random/vite.config.ts +11 -0
- package/dist/scaffolds.json +27 -4
- package/package.json +2 -1
- package/skills/notis-apps/SKILL.md +2 -2
- package/skills/notis-cli/SKILL.md +18 -16
- package/skills/notis-query/cli.md +9 -10
- package/src/cli.js +30 -2
- package/src/command-specs/apps.js +42 -15
- package/src/command-specs/helpers.js +73 -45
- package/src/command-specs/meta.js +7 -7
- package/src/command-specs/tools.js +196 -83
- package/src/runtime/app-boundary-validator.js +65 -14
- package/src/runtime/profiles.js +10 -4
- package/src/runtime/transport.js +122 -37
- package/template/app/page.tsx +1 -1
- package/template/notis.config.ts +1 -1
- package/template/packages/sdk/src/hooks/useTool.ts +1 -1
- /package/dist/scaffolds/{notes → notis-database}/app/layout.tsx +0 -0
- /package/dist/scaffolds/{notes → notis-database}/components/ui/badge.tsx +0 -0
- /package/dist/scaffolds/{notes → notis-database}/components/ui/button.tsx +0 -0
- /package/dist/scaffolds/{notes → notis-database}/components/ui/card.tsx +0 -0
- /package/dist/scaffolds/{notes → notis-database}/components.json +0 -0
- /package/dist/scaffolds/{notes → notis-database}/lib/utils.ts +0 -0
- /package/dist/scaffolds/{notes → notis-database}/postcss.config.mjs +0 -0
- /package/dist/scaffolds/{notes → notis-notes}/app/globals.css +0 -0
- /package/dist/scaffolds/{notes → notis-notes}/tailwind.config.ts +0 -0
- /package/dist/scaffolds/{notes → notis-notes}/vite.config.ts +0 -0
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useTool, useTopBarSearch } from '@notis/sdk';
|
|
5
|
+
import { WarningCircleIcon as AlertCircle, AtIcon as AtSign, CalendarIcon as Calendar, CheckSquareIcon as CheckSquare, CaretDownIcon as ChevronDown, CircleIcon as Circle, CircleIcon as CircleDot, DatabaseIcon as Database, FileTextIcon as FileText, HashIcon as Hash, LinkIcon as Link2, ListChecksIcon as ListChecks, CircleNotchIcon as Loader2, EnvelopeIcon as Mail, PaperclipIcon as Paperclip, PhoneIcon as Phone, SigmaIcon as Sigma, TagIcon as Tag, TextTIcon as Type, UserIcon as User } from '@phosphor-icons/react';
|
|
6
|
+
|
|
7
|
+
import { Badge } from '@/components/ui/badge';
|
|
8
|
+
import {
|
|
9
|
+
Table,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableCell,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableRow,
|
|
15
|
+
} from '@/components/ui/table';
|
|
16
|
+
import { cn } from '@/lib/utils';
|
|
17
|
+
import type {
|
|
18
|
+
DatabaseCatalogRow,
|
|
19
|
+
DatabaseDetail,
|
|
20
|
+
DatabaseDocument,
|
|
21
|
+
DatabaseProperty,
|
|
22
|
+
DocumentPropertyValue,
|
|
23
|
+
GetDatabaseArgs,
|
|
24
|
+
GetDatabaseResult,
|
|
25
|
+
ListDatabasesArgs,
|
|
26
|
+
ListDatabasesResult,
|
|
27
|
+
QueryDatabaseArgs,
|
|
28
|
+
QueryDatabaseResult,
|
|
29
|
+
} from '@/lib/types';
|
|
30
|
+
|
|
31
|
+
const TYPE_LABEL: Record<string, string> = {
|
|
32
|
+
title: 'Text',
|
|
33
|
+
rich_text: 'Text',
|
|
34
|
+
number: 'Number',
|
|
35
|
+
select: 'Select',
|
|
36
|
+
multi_select: 'Multi-select',
|
|
37
|
+
status: 'Status',
|
|
38
|
+
checkbox: 'Checkbox',
|
|
39
|
+
url: 'URL',
|
|
40
|
+
email: 'Email',
|
|
41
|
+
phone_number: 'Phone',
|
|
42
|
+
date: 'Date',
|
|
43
|
+
files: 'Files',
|
|
44
|
+
relation: 'Relation',
|
|
45
|
+
formula: 'Formula',
|
|
46
|
+
people: 'People',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function PropertyTypeIcon({ type }: { type: string }) {
|
|
50
|
+
const className = 'h-3.5 w-3.5 text-muted-foreground';
|
|
51
|
+
switch (type) {
|
|
52
|
+
case 'title':
|
|
53
|
+
return <FileText className={className} />;
|
|
54
|
+
case 'rich_text':
|
|
55
|
+
return <Type className={className} />;
|
|
56
|
+
case 'number':
|
|
57
|
+
return <Hash className={className} />;
|
|
58
|
+
case 'select':
|
|
59
|
+
return <CircleDot className={className} />;
|
|
60
|
+
case 'multi_select':
|
|
61
|
+
return <Tag className={className} />;
|
|
62
|
+
case 'status':
|
|
63
|
+
return <Circle className={className} />;
|
|
64
|
+
case 'checkbox':
|
|
65
|
+
return <CheckSquare className={className} />;
|
|
66
|
+
case 'url':
|
|
67
|
+
return <Link2 className={className} />;
|
|
68
|
+
case 'email':
|
|
69
|
+
return <Mail className={className} />;
|
|
70
|
+
case 'phone_number':
|
|
71
|
+
return <Phone className={className} />;
|
|
72
|
+
case 'date':
|
|
73
|
+
return <Calendar className={className} />;
|
|
74
|
+
case 'files':
|
|
75
|
+
return <Paperclip className={className} />;
|
|
76
|
+
case 'relation':
|
|
77
|
+
return <Link2 className={className} />;
|
|
78
|
+
case 'formula':
|
|
79
|
+
return <Sigma className={className} />;
|
|
80
|
+
case 'people':
|
|
81
|
+
return <User className={className} />;
|
|
82
|
+
default:
|
|
83
|
+
return <AtSign className={className} />;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const TILE_TINTS = [
|
|
88
|
+
'bg-blue-100/70 text-blue-900 dark:bg-blue-500/15 dark:text-blue-200',
|
|
89
|
+
'bg-amber-100/70 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200',
|
|
90
|
+
'bg-pink-100/70 text-pink-900 dark:bg-pink-500/15 dark:text-pink-200',
|
|
91
|
+
'bg-emerald-100/70 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-200',
|
|
92
|
+
'bg-violet-100/70 text-violet-900 dark:bg-violet-500/15 dark:text-violet-200',
|
|
93
|
+
'bg-rose-100/70 text-rose-900 dark:bg-rose-500/15 dark:text-rose-200',
|
|
94
|
+
'bg-sky-100/70 text-sky-900 dark:bg-sky-500/15 dark:text-sky-200',
|
|
95
|
+
'bg-orange-100/70 text-orange-900 dark:bg-orange-500/15 dark:text-orange-200',
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
function tileTintFor(seed: string | null | undefined): string {
|
|
99
|
+
if (!seed) return TILE_TINTS[0];
|
|
100
|
+
let hash = 0;
|
|
101
|
+
for (let i = 0; i < seed.length; i++) {
|
|
102
|
+
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
|
103
|
+
}
|
|
104
|
+
return TILE_TINTS[hash % TILE_TINTS.length];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function compactNumber(n: number | null | undefined): string {
|
|
108
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) return '0';
|
|
109
|
+
return new Intl.NumberFormat('en-US').format(n);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolvedDocumentCount(
|
|
113
|
+
database: Pick<DatabaseCatalogRow, 'documents_count' | 'successful_documents' | 'total_documents'> | null | undefined,
|
|
114
|
+
): number {
|
|
115
|
+
const count = database?.documents_count ?? database?.successful_documents ?? database?.total_documents;
|
|
116
|
+
if (typeof count !== 'number' || !Number.isFinite(count)) return 0;
|
|
117
|
+
return Math.max(0, count);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatDateTime(value: string | null | undefined): string {
|
|
121
|
+
if (!value) return '—';
|
|
122
|
+
const parsed = new Date(value);
|
|
123
|
+
if (Number.isNaN(parsed.getTime())) return value;
|
|
124
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
125
|
+
month: 'short',
|
|
126
|
+
day: 'numeric',
|
|
127
|
+
year: 'numeric',
|
|
128
|
+
hour: 'numeric',
|
|
129
|
+
minute: '2-digit',
|
|
130
|
+
}).format(parsed);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatDate(value: string | null | undefined): string {
|
|
134
|
+
if (!value) return '—';
|
|
135
|
+
const parsed = new Date(value);
|
|
136
|
+
if (Number.isNaN(parsed.getTime())) return value;
|
|
137
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
138
|
+
month: 'short',
|
|
139
|
+
day: 'numeric',
|
|
140
|
+
year: 'numeric',
|
|
141
|
+
}).format(parsed);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function textFromRichText(value: unknown): string {
|
|
145
|
+
if (typeof value === 'string') return value;
|
|
146
|
+
if (!Array.isArray(value)) return '';
|
|
147
|
+
return value
|
|
148
|
+
.map((chunk) => {
|
|
149
|
+
if (!chunk || typeof chunk !== 'object') return '';
|
|
150
|
+
const item = chunk as Record<string, unknown>;
|
|
151
|
+
if (typeof item.plain_text === 'string') return item.plain_text;
|
|
152
|
+
const text = item.text;
|
|
153
|
+
if (text && typeof text === 'object') {
|
|
154
|
+
const content = (text as Record<string, unknown>).content;
|
|
155
|
+
if (typeof content === 'string') return content;
|
|
156
|
+
}
|
|
157
|
+
if (typeof item.content === 'string') return item.content;
|
|
158
|
+
return '';
|
|
159
|
+
})
|
|
160
|
+
.join('');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function relationLabel(value: unknown, noun: string): string {
|
|
164
|
+
const count = Array.isArray(value) ? value.length : 0;
|
|
165
|
+
return `${compactNumber(count)} ${noun}${count === 1 ? '' : 's'}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const SELECT_TINTS: Record<string, string> = {
|
|
169
|
+
default: 'bg-muted text-foreground',
|
|
170
|
+
gray: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-500/20 dark:text-zinc-200',
|
|
171
|
+
brown: 'bg-amber-200/60 text-amber-900 dark:bg-amber-500/20 dark:text-amber-200',
|
|
172
|
+
orange: 'bg-orange-100 text-orange-900 dark:bg-orange-500/20 dark:text-orange-200',
|
|
173
|
+
yellow: 'bg-amber-100 text-amber-900 dark:bg-amber-500/20 dark:text-amber-200',
|
|
174
|
+
green: 'bg-emerald-100 text-emerald-900 dark:bg-emerald-500/20 dark:text-emerald-200',
|
|
175
|
+
blue: 'bg-sky-100 text-sky-900 dark:bg-sky-500/20 dark:text-sky-200',
|
|
176
|
+
purple: 'bg-violet-100 text-violet-900 dark:bg-violet-500/20 dark:text-violet-200',
|
|
177
|
+
pink: 'bg-pink-100 text-pink-900 dark:bg-pink-500/20 dark:text-pink-200',
|
|
178
|
+
red: 'bg-rose-100 text-rose-900 dark:bg-rose-500/20 dark:text-rose-200',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function selectTint(color: string | null | undefined): string {
|
|
182
|
+
if (!color) return SELECT_TINTS.default;
|
|
183
|
+
return SELECT_TINTS[color] ?? SELECT_TINTS.default;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type Group = {
|
|
187
|
+
key: string;
|
|
188
|
+
label: string;
|
|
189
|
+
rows: DatabaseCatalogRow[];
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
function groupRows(rows: DatabaseCatalogRow[]): Group[] {
|
|
193
|
+
const map = new Map<string, Group>();
|
|
194
|
+
for (const row of rows) {
|
|
195
|
+
const key = row.owner_app_id ?? '__workspace__';
|
|
196
|
+
const label = row.owner_app_name ?? 'Workspace';
|
|
197
|
+
if (!map.has(key)) {
|
|
198
|
+
map.set(key, { key, label, rows: [] });
|
|
199
|
+
}
|
|
200
|
+
map.get(key)!.rows.push(row);
|
|
201
|
+
}
|
|
202
|
+
for (const group of map.values()) {
|
|
203
|
+
group.rows.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
|
204
|
+
}
|
|
205
|
+
return Array.from(map.values()).sort((a, b) => a.label.localeCompare(b.label));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function relationCount(database: DatabaseDetail | null): number {
|
|
209
|
+
if (!database?.schema?.properties) return 0;
|
|
210
|
+
return database.schema.properties.filter((p) => p.type === 'relation').length;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function nonRelationProps(database: DatabaseDetail | null): DatabaseProperty[] {
|
|
214
|
+
if (!database?.schema?.properties) return [];
|
|
215
|
+
return database.schema.properties.filter((p) => p.type !== 'relation');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function relationProps(database: DatabaseDetail | null): DatabaseProperty[] {
|
|
219
|
+
if (!database?.schema?.properties) return [];
|
|
220
|
+
return database.schema.properties.filter((p) => p.type === 'relation');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type ActiveTab = 'properties' | 'relations' | 'documents';
|
|
224
|
+
|
|
225
|
+
export default function CatalogPage() {
|
|
226
|
+
const listDatabases = useTool<ListDatabasesArgs, ListDatabasesResult>(
|
|
227
|
+
'LOCAL_NOTIS_DATABASE_LIST_DATABASES',
|
|
228
|
+
);
|
|
229
|
+
const getDatabase = useTool<GetDatabaseArgs, GetDatabaseResult>(
|
|
230
|
+
'LOCAL_NOTIS_DATABASE_GET_DATABASE',
|
|
231
|
+
);
|
|
232
|
+
const queryDatabase = useTool<QueryDatabaseArgs, QueryDatabaseResult>(
|
|
233
|
+
'LOCAL_NOTIS_DATABASE_QUERY',
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const [rows, setRows] = useState<DatabaseCatalogRow[]>([]);
|
|
237
|
+
const [listError, setListError] = useState<string | null>(null);
|
|
238
|
+
const [listLoading, setListLoading] = useState(true);
|
|
239
|
+
|
|
240
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
241
|
+
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
|
242
|
+
const [search, setSearch] = useState('');
|
|
243
|
+
|
|
244
|
+
const [detail, setDetail] = useState<DatabaseDetail | null>(null);
|
|
245
|
+
const [detailError, setDetailError] = useState<string | null>(null);
|
|
246
|
+
const [detailLoading, setDetailLoading] = useState(false);
|
|
247
|
+
const [activeTab, setActiveTab] = useState<ActiveTab>('properties');
|
|
248
|
+
|
|
249
|
+
const detailRequestId = useRef(0);
|
|
250
|
+
const documentRequestId = useRef(0);
|
|
251
|
+
|
|
252
|
+
const [documents, setDocuments] = useState<DatabaseDocument[]>([]);
|
|
253
|
+
const [documentsLoading, setDocumentsLoading] = useState(false);
|
|
254
|
+
const [documentsError, setDocumentsError] = useState<string | null>(null);
|
|
255
|
+
|
|
256
|
+
const listCall = listDatabases.call;
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
let cancelled = false;
|
|
259
|
+
setListLoading(true);
|
|
260
|
+
setListError(null);
|
|
261
|
+
listCall({} as ListDatabasesArgs)
|
|
262
|
+
.then((result) => {
|
|
263
|
+
if (cancelled) return;
|
|
264
|
+
const next = (result?.databases ?? []).filter((db): db is DatabaseCatalogRow =>
|
|
265
|
+
Boolean(db && typeof db.id === 'string'),
|
|
266
|
+
);
|
|
267
|
+
setRows(next);
|
|
268
|
+
setSelectedId((current) => {
|
|
269
|
+
if (current && next.some((row) => row.id === current)) return current;
|
|
270
|
+
return next[0]?.id ?? null;
|
|
271
|
+
});
|
|
272
|
+
})
|
|
273
|
+
.catch((err: unknown) => {
|
|
274
|
+
if (cancelled) return;
|
|
275
|
+
setListError(err instanceof Error ? err.message : 'Failed to load databases.');
|
|
276
|
+
})
|
|
277
|
+
.finally(() => {
|
|
278
|
+
if (!cancelled) setListLoading(false);
|
|
279
|
+
});
|
|
280
|
+
return () => {
|
|
281
|
+
cancelled = true;
|
|
282
|
+
};
|
|
283
|
+
}, [listCall]);
|
|
284
|
+
|
|
285
|
+
const getCall = getDatabase.call;
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
if (!selectedId) {
|
|
288
|
+
setDetail(null);
|
|
289
|
+
setDetailError(null);
|
|
290
|
+
setDetailLoading(false);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const requestId = ++detailRequestId.current;
|
|
294
|
+
setDetailLoading(true);
|
|
295
|
+
setDetailError(null);
|
|
296
|
+
setActiveTab('properties');
|
|
297
|
+
setDocuments([]);
|
|
298
|
+
setDocumentsError(null);
|
|
299
|
+
setDocumentsLoading(false);
|
|
300
|
+
getCall({ database_id: selectedId })
|
|
301
|
+
.then((result) => {
|
|
302
|
+
if (requestId !== detailRequestId.current) return;
|
|
303
|
+
if (result && 'database' in result && result.database) {
|
|
304
|
+
setDetail(result.database);
|
|
305
|
+
} else {
|
|
306
|
+
const message =
|
|
307
|
+
result && 'message' in result && typeof result.message === 'string'
|
|
308
|
+
? result.message
|
|
309
|
+
: 'Database not found.';
|
|
310
|
+
setDetail(null);
|
|
311
|
+
setDetailError(message);
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
.catch((err: unknown) => {
|
|
315
|
+
if (requestId !== detailRequestId.current) return;
|
|
316
|
+
setDetail(null);
|
|
317
|
+
setDetailError(err instanceof Error ? err.message : 'Failed to load database.');
|
|
318
|
+
})
|
|
319
|
+
.finally(() => {
|
|
320
|
+
if (requestId === detailRequestId.current) setDetailLoading(false);
|
|
321
|
+
});
|
|
322
|
+
}, [selectedId, getCall]);
|
|
323
|
+
|
|
324
|
+
const queryCall = queryDatabase.call;
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
if (activeTab !== 'documents') return;
|
|
327
|
+
if (!detail) return;
|
|
328
|
+
|
|
329
|
+
const databaseSlug = detail.slug;
|
|
330
|
+
if (!databaseSlug) {
|
|
331
|
+
setDocuments([]);
|
|
332
|
+
setDocumentsError('This database does not have a queryable slug.');
|
|
333
|
+
setDocumentsLoading(false);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const requestId = ++documentRequestId.current;
|
|
338
|
+
setDocuments([]);
|
|
339
|
+
setDocumentsError(null);
|
|
340
|
+
setDocumentsLoading(true);
|
|
341
|
+
|
|
342
|
+
const loadDocuments = async () => {
|
|
343
|
+
const pageSize = 100;
|
|
344
|
+
const maxPages = 1000;
|
|
345
|
+
let offset = 0;
|
|
346
|
+
let collected: DatabaseDocument[] = [];
|
|
347
|
+
|
|
348
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
349
|
+
const result = await queryCall({
|
|
350
|
+
database_slug: databaseSlug,
|
|
351
|
+
query: { page_size: pageSize },
|
|
352
|
+
offset,
|
|
353
|
+
});
|
|
354
|
+
if (requestId !== documentRequestId.current) return;
|
|
355
|
+
|
|
356
|
+
if (!result || result.status === 'error') {
|
|
357
|
+
throw new Error(result?.message || 'Failed to load documents.');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
collected = [...collected, ...(result.documents ?? [])];
|
|
361
|
+
setDocuments(collected);
|
|
362
|
+
|
|
363
|
+
if (!result.has_more) return;
|
|
364
|
+
if (typeof result.next_offset !== 'number') {
|
|
365
|
+
throw new Error('The document query did not return the next page offset.');
|
|
366
|
+
}
|
|
367
|
+
offset = result.next_offset;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
throw new Error('Document query exceeded the pagination safety limit.');
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
loadDocuments()
|
|
374
|
+
.catch((err: unknown) => {
|
|
375
|
+
if (requestId !== documentRequestId.current) return;
|
|
376
|
+
setDocumentsError(err instanceof Error ? err.message : 'Failed to load documents.');
|
|
377
|
+
})
|
|
378
|
+
.finally(() => {
|
|
379
|
+
if (requestId === documentRequestId.current) setDocumentsLoading(false);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
return () => {
|
|
383
|
+
if (requestId === documentRequestId.current) {
|
|
384
|
+
documentRequestId.current += 1;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}, [activeTab, detail, queryCall]);
|
|
388
|
+
|
|
389
|
+
const handleSearchChange = useCallback((next: string) => setSearch(next), []);
|
|
390
|
+
useTopBarSearch({
|
|
391
|
+
value: search,
|
|
392
|
+
onChange: handleSearchChange,
|
|
393
|
+
placeholder: 'Search databases',
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const filteredRows = useMemo(() => {
|
|
397
|
+
const q = search.trim().toLowerCase();
|
|
398
|
+
if (!q) return rows;
|
|
399
|
+
return rows.filter((row) => {
|
|
400
|
+
const haystack = [row.name, row.slug, row.description, row.owner_app_name]
|
|
401
|
+
.filter((v): v is string => typeof v === 'string')
|
|
402
|
+
.join(' ')
|
|
403
|
+
.toLowerCase();
|
|
404
|
+
return haystack.includes(q);
|
|
405
|
+
});
|
|
406
|
+
}, [rows, search]);
|
|
407
|
+
|
|
408
|
+
const groups = useMemo(() => groupRows(filteredRows), [filteredRows]);
|
|
409
|
+
const docCount = resolvedDocumentCount(detail);
|
|
410
|
+
const relCount = relationCount(detail);
|
|
411
|
+
const propCount = nonRelationProps(detail).length;
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground antialiased">
|
|
415
|
+
<aside className="flex h-full w-[288px] flex-shrink-0 flex-col border-r border-border bg-background">
|
|
416
|
+
<div className="flex items-baseline justify-between gap-2 px-4 pb-3 pt-4">
|
|
417
|
+
<h1 className="text-lg font-semibold tracking-tight">Databases</h1>
|
|
418
|
+
<span className="text-[11px] tabular-nums text-muted-foreground">
|
|
419
|
+
{compactNumber(rows.length)}
|
|
420
|
+
</span>
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
<div className="flex-1 overflow-y-auto px-2.5 pb-4">
|
|
424
|
+
{listLoading ? (
|
|
425
|
+
<ListSkeleton />
|
|
426
|
+
) : listError ? (
|
|
427
|
+
<ListError message={listError} />
|
|
428
|
+
) : groups.length === 0 ? (
|
|
429
|
+
<ListEmpty hasSearch={Boolean(search.trim())} />
|
|
430
|
+
) : (
|
|
431
|
+
groups.map((group) => {
|
|
432
|
+
const collapsed = collapsedGroups[group.key];
|
|
433
|
+
return (
|
|
434
|
+
<div key={group.key} className="mt-2 first:mt-0">
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
onClick={() =>
|
|
438
|
+
setCollapsedGroups((prev) => ({
|
|
439
|
+
...prev,
|
|
440
|
+
[group.key]: !prev[group.key],
|
|
441
|
+
}))
|
|
442
|
+
}
|
|
443
|
+
className="flex w-full items-center gap-1.5 px-2 pb-1.5 pt-1 text-left transition-colors hover:text-foreground"
|
|
444
|
+
>
|
|
445
|
+
<ChevronDown
|
|
446
|
+
className={cn(
|
|
447
|
+
'h-2.5 w-2.5 text-foreground transition-transform',
|
|
448
|
+
collapsed && '-rotate-90',
|
|
449
|
+
)}
|
|
450
|
+
/>
|
|
451
|
+
<span className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
|
452
|
+
{group.label}
|
|
453
|
+
</span>
|
|
454
|
+
<span className="ml-1 h-px flex-1 bg-border/70" />
|
|
455
|
+
</button>
|
|
456
|
+
{!collapsed &&
|
|
457
|
+
group.rows.map((row) => (
|
|
458
|
+
<CatalogRow
|
|
459
|
+
key={row.id}
|
|
460
|
+
row={row}
|
|
461
|
+
active={row.id === selectedId}
|
|
462
|
+
onSelect={() => setSelectedId(row.id)}
|
|
463
|
+
/>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
);
|
|
467
|
+
})
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
</aside>
|
|
471
|
+
|
|
472
|
+
<section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
|
473
|
+
{listLoading || detailLoading || (selectedId && !detail && !detailError) ? (
|
|
474
|
+
<DetailSkeleton />
|
|
475
|
+
) : detailError ? (
|
|
476
|
+
<DetailError message={detailError} />
|
|
477
|
+
) : !detail ? (
|
|
478
|
+
<DetailEmpty hasRows={rows.length > 0} />
|
|
479
|
+
) : (
|
|
480
|
+
<>
|
|
481
|
+
<DetailHeader
|
|
482
|
+
database={detail}
|
|
483
|
+
activeTab={activeTab}
|
|
484
|
+
onTabChange={setActiveTab}
|
|
485
|
+
propCount={propCount}
|
|
486
|
+
relCount={relCount}
|
|
487
|
+
docCount={docCount}
|
|
488
|
+
/>
|
|
489
|
+
<div className="flex-1 overflow-y-auto px-4 py-2">
|
|
490
|
+
{activeTab === 'properties' && (
|
|
491
|
+
<PropertyList properties={nonRelationProps(detail)} />
|
|
492
|
+
)}
|
|
493
|
+
{activeTab === 'relations' && (
|
|
494
|
+
<RelationsList properties={relationProps(detail)} />
|
|
495
|
+
)}
|
|
496
|
+
{activeTab === 'documents' && (
|
|
497
|
+
<DocumentsTable
|
|
498
|
+
database={detail}
|
|
499
|
+
documents={documents}
|
|
500
|
+
loading={documentsLoading}
|
|
501
|
+
error={documentsError}
|
|
502
|
+
expectedCount={docCount}
|
|
503
|
+
/>
|
|
504
|
+
)}
|
|
505
|
+
</div>
|
|
506
|
+
</>
|
|
507
|
+
)}
|
|
508
|
+
</section>
|
|
509
|
+
</div>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function CatalogRow({
|
|
514
|
+
row,
|
|
515
|
+
active,
|
|
516
|
+
onSelect,
|
|
517
|
+
}: {
|
|
518
|
+
row: DatabaseCatalogRow;
|
|
519
|
+
active: boolean;
|
|
520
|
+
onSelect: () => void;
|
|
521
|
+
}) {
|
|
522
|
+
const docs = resolvedDocumentCount(row);
|
|
523
|
+
const tint = tileTintFor(row.slug ?? row.id);
|
|
524
|
+
return (
|
|
525
|
+
<button
|
|
526
|
+
type="button"
|
|
527
|
+
onClick={onSelect}
|
|
528
|
+
className={cn(
|
|
529
|
+
'flex w-full items-center gap-2.5 rounded-md p-2 text-left transition-colors',
|
|
530
|
+
active ? 'bg-foreground text-background' : 'hover:bg-muted/60 text-foreground',
|
|
531
|
+
)}
|
|
532
|
+
>
|
|
533
|
+
<span
|
|
534
|
+
className={cn(
|
|
535
|
+
'flex size-6 flex-shrink-0 items-center justify-center rounded-md',
|
|
536
|
+
active ? 'bg-background/15 text-background' : tint,
|
|
537
|
+
)}
|
|
538
|
+
>
|
|
539
|
+
<Database className="h-3 w-3" />
|
|
540
|
+
</span>
|
|
541
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
542
|
+
<span
|
|
543
|
+
className={cn(
|
|
544
|
+
'truncate text-[13px] font-medium tracking-[-0.005em]',
|
|
545
|
+
active ? 'text-background' : 'text-foreground',
|
|
546
|
+
)}
|
|
547
|
+
>
|
|
548
|
+
{row.name ?? row.slug ?? 'Untitled'}
|
|
549
|
+
</span>
|
|
550
|
+
<span
|
|
551
|
+
className={cn(
|
|
552
|
+
'truncate text-[11px] tabular-nums',
|
|
553
|
+
active ? 'text-background/60' : 'text-muted-foreground',
|
|
554
|
+
)}
|
|
555
|
+
>
|
|
556
|
+
{compactNumber(docs)} docs
|
|
557
|
+
</span>
|
|
558
|
+
</span>
|
|
559
|
+
</button>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function ListSkeleton() {
|
|
564
|
+
return (
|
|
565
|
+
<div className="space-y-1.5 p-1.5">
|
|
566
|
+
{[0, 1, 2, 3, 4].map((i) => (
|
|
567
|
+
<div
|
|
568
|
+
key={i}
|
|
569
|
+
className="flex items-center gap-2.5 rounded-md p-2"
|
|
570
|
+
>
|
|
571
|
+
<div className="size-6 animate-pulse rounded-md bg-muted" />
|
|
572
|
+
<div className="flex flex-1 flex-col gap-1.5">
|
|
573
|
+
<div className="h-3 w-24 animate-pulse rounded bg-muted" />
|
|
574
|
+
<div className="h-2 w-16 animate-pulse rounded bg-muted/70" />
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
))}
|
|
578
|
+
</div>
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function ListError({ message }: { message: string }) {
|
|
583
|
+
return (
|
|
584
|
+
<div className="mt-6 flex flex-col items-center gap-2 rounded-md border border-dashed border-border px-3 py-6 text-center">
|
|
585
|
+
<AlertCircle className="h-4 w-4 text-destructive" />
|
|
586
|
+
<p className="text-xs text-muted-foreground">{message}</p>
|
|
587
|
+
</div>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function ListEmpty({ hasSearch }: { hasSearch: boolean }) {
|
|
592
|
+
return (
|
|
593
|
+
<div className="mt-12 flex flex-col items-center px-3 text-center">
|
|
594
|
+
<Database className="mb-3 h-8 w-8 text-muted-foreground/30" strokeWidth={1.5} />
|
|
595
|
+
<p className="text-sm font-medium text-foreground">
|
|
596
|
+
{hasSearch ? 'No matches' : 'No databases yet'}
|
|
597
|
+
</p>
|
|
598
|
+
<p className="mt-1 max-w-[220px] text-[11px] text-muted-foreground">
|
|
599
|
+
{hasSearch
|
|
600
|
+
? 'Try a different name or app.'
|
|
601
|
+
: 'Databases created in your Notis workspace will appear here.'}
|
|
602
|
+
</p>
|
|
603
|
+
</div>
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function DetailSkeleton() {
|
|
608
|
+
return (
|
|
609
|
+
<div className="flex flex-1 items-center justify-center">
|
|
610
|
+
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
611
|
+
</div>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function DetailError({ message }: { message: string }) {
|
|
616
|
+
return (
|
|
617
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-10 text-center">
|
|
618
|
+
<AlertCircle className="h-6 w-6 text-destructive" />
|
|
619
|
+
<p className="text-sm font-medium text-foreground">Couldn't load this database</p>
|
|
620
|
+
<p className="max-w-[42ch] text-xs text-muted-foreground">{message}</p>
|
|
621
|
+
</div>
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function DetailEmpty({ hasRows }: { hasRows: boolean }) {
|
|
626
|
+
return (
|
|
627
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-10 text-center">
|
|
628
|
+
<Database className="h-10 w-10 text-muted-foreground/25" strokeWidth={1.4} />
|
|
629
|
+
<p className="text-sm font-semibold text-foreground">
|
|
630
|
+
{hasRows ? 'Select a database' : 'No databases yet'}
|
|
631
|
+
</p>
|
|
632
|
+
<p className="max-w-[42ch] text-xs text-muted-foreground">
|
|
633
|
+
{hasRows
|
|
634
|
+
? 'Pick a database on the left to view its schema, properties, and relations.'
|
|
635
|
+
: 'Databases created in your Notis workspace will appear here.'}
|
|
636
|
+
</p>
|
|
637
|
+
</div>
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function DetailHeader({
|
|
642
|
+
database,
|
|
643
|
+
activeTab,
|
|
644
|
+
onTabChange,
|
|
645
|
+
propCount,
|
|
646
|
+
relCount,
|
|
647
|
+
docCount,
|
|
648
|
+
}: {
|
|
649
|
+
database: DatabaseDetail;
|
|
650
|
+
activeTab: ActiveTab;
|
|
651
|
+
onTabChange: (tab: ActiveTab) => void;
|
|
652
|
+
propCount: number;
|
|
653
|
+
relCount: number;
|
|
654
|
+
docCount: number;
|
|
655
|
+
}) {
|
|
656
|
+
const tint = tileTintFor(database.slug ?? database.id);
|
|
657
|
+
const eyebrow = [database.owner_app_name ?? 'Workspace', 'Database']
|
|
658
|
+
.filter(Boolean)
|
|
659
|
+
.join(' · ');
|
|
660
|
+
|
|
661
|
+
return (
|
|
662
|
+
<header className="flex flex-col gap-3.5 border-b border-border px-10 pb-5 pt-9">
|
|
663
|
+
<div className="flex items-center gap-3.5">
|
|
664
|
+
<span
|
|
665
|
+
className={cn(
|
|
666
|
+
'flex size-11 flex-shrink-0 items-center justify-center rounded-[11px]',
|
|
667
|
+
tint,
|
|
668
|
+
)}
|
|
669
|
+
>
|
|
670
|
+
<Database className="h-[22px] w-[22px]" strokeWidth={1.7} />
|
|
671
|
+
</span>
|
|
672
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
673
|
+
<span className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
|
674
|
+
{eyebrow.toUpperCase()}
|
|
675
|
+
</span>
|
|
676
|
+
<h2 className="truncate text-2xl font-semibold tracking-[-0.02em] text-foreground">
|
|
677
|
+
{database.name ?? database.slug ?? 'Untitled database'}
|
|
678
|
+
</h2>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
682
|
+
{database.description ? (
|
|
683
|
+
<p className="max-w-[60ch] text-sm leading-6 text-muted-foreground">
|
|
684
|
+
{database.description}
|
|
685
|
+
</p>
|
|
686
|
+
) : null}
|
|
687
|
+
|
|
688
|
+
<div className="mt-1.5 flex items-center gap-1">
|
|
689
|
+
<TabButton
|
|
690
|
+
icon={<ListChecks className="h-3 w-3" />}
|
|
691
|
+
label="Properties"
|
|
692
|
+
count={propCount}
|
|
693
|
+
active={activeTab === 'properties'}
|
|
694
|
+
onClick={() => onTabChange('properties')}
|
|
695
|
+
/>
|
|
696
|
+
<TabButton
|
|
697
|
+
icon={<Link2 className="h-3 w-3" />}
|
|
698
|
+
label="Relations"
|
|
699
|
+
count={relCount}
|
|
700
|
+
active={activeTab === 'relations'}
|
|
701
|
+
onClick={() => onTabChange('relations')}
|
|
702
|
+
/>
|
|
703
|
+
<TabButton
|
|
704
|
+
icon={<Database className="h-3 w-3" />}
|
|
705
|
+
label="Documents"
|
|
706
|
+
count={docCount}
|
|
707
|
+
active={activeTab === 'documents'}
|
|
708
|
+
onClick={() => onTabChange('documents')}
|
|
709
|
+
/>
|
|
710
|
+
</div>
|
|
711
|
+
</header>
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function TabButton({
|
|
716
|
+
icon,
|
|
717
|
+
label,
|
|
718
|
+
count,
|
|
719
|
+
active,
|
|
720
|
+
onClick,
|
|
721
|
+
}: {
|
|
722
|
+
icon: React.ReactNode;
|
|
723
|
+
label: string;
|
|
724
|
+
count: number;
|
|
725
|
+
active: boolean;
|
|
726
|
+
onClick: () => void;
|
|
727
|
+
}) {
|
|
728
|
+
return (
|
|
729
|
+
<button
|
|
730
|
+
type="button"
|
|
731
|
+
onClick={onClick}
|
|
732
|
+
className={cn(
|
|
733
|
+
'flex items-center gap-1.5 px-3.5 py-1.5 text-[12.5px] transition-colors',
|
|
734
|
+
active
|
|
735
|
+
? 'border-b-[1.5px] border-foreground font-medium text-foreground'
|
|
736
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
737
|
+
)}
|
|
738
|
+
>
|
|
739
|
+
{icon}
|
|
740
|
+
<span>{label}</span>
|
|
741
|
+
<span className={cn('tabular-nums', active ? 'text-muted-foreground' : 'text-muted-foreground/70')}>
|
|
742
|
+
{compactNumber(count)}
|
|
743
|
+
</span>
|
|
744
|
+
</button>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function PropertyList({ properties }: { properties: DatabaseProperty[] }) {
|
|
749
|
+
if (properties.length === 0) {
|
|
750
|
+
return <SectionEmpty label="No properties defined." />;
|
|
751
|
+
}
|
|
752
|
+
return (
|
|
753
|
+
<div className="flex flex-col">
|
|
754
|
+
{properties.map((prop, index) => (
|
|
755
|
+
<PropertyRow key={prop.id} property={prop} striped={index % 2 === 1} />
|
|
756
|
+
))}
|
|
757
|
+
</div>
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function PropertyRow({ property, striped }: { property: DatabaseProperty; striped: boolean }) {
|
|
762
|
+
const typeLabel = TYPE_LABEL[property.type] ?? property.type;
|
|
763
|
+
const isTitle = property.type === 'title';
|
|
764
|
+
const options = property.options ?? [];
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<div
|
|
768
|
+
className={cn(
|
|
769
|
+
'flex flex-col gap-1.5 rounded-lg px-4 py-3.5',
|
|
770
|
+
striped && 'bg-muted/40',
|
|
771
|
+
)}
|
|
772
|
+
>
|
|
773
|
+
<div className="flex items-center gap-2.5">
|
|
774
|
+
<PropertyTypeIcon type={property.type} />
|
|
775
|
+
<span className="text-[13.5px] font-semibold tracking-[-0.005em] text-foreground">
|
|
776
|
+
{property.name ?? 'Untitled'}
|
|
777
|
+
</span>
|
|
778
|
+
{isTitle ? (
|
|
779
|
+
<Badge
|
|
780
|
+
variant="secondary"
|
|
781
|
+
className="rounded-sm border-transparent bg-foreground/[0.08] px-1.5 py-px text-[10.5px] font-medium uppercase tracking-[0.02em] text-muted-foreground"
|
|
782
|
+
>
|
|
783
|
+
Title
|
|
784
|
+
</Badge>
|
|
785
|
+
) : null}
|
|
786
|
+
<span className="flex-1" />
|
|
787
|
+
<span className="text-[11px] text-muted-foreground">{typeLabel}</span>
|
|
788
|
+
</div>
|
|
789
|
+
|
|
790
|
+
{options.length > 0 ? (
|
|
791
|
+
<div className="flex flex-wrap items-center gap-1.5 pl-6">
|
|
792
|
+
{options.map((option, index) => (
|
|
793
|
+
<span
|
|
794
|
+
key={option.id ?? option.name ?? index}
|
|
795
|
+
className={cn(
|
|
796
|
+
'inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-medium',
|
|
797
|
+
selectTint(option.color),
|
|
798
|
+
)}
|
|
799
|
+
>
|
|
800
|
+
{option.name ?? '—'}
|
|
801
|
+
</span>
|
|
802
|
+
))}
|
|
803
|
+
</div>
|
|
804
|
+
) : null}
|
|
805
|
+
|
|
806
|
+
{property.format ? (
|
|
807
|
+
<div className="pl-6">
|
|
808
|
+
<span className="inline-flex items-center rounded-md border border-border bg-card px-2 py-0.5 text-[11px] text-muted-foreground">
|
|
809
|
+
Format · {property.format}
|
|
810
|
+
</span>
|
|
811
|
+
</div>
|
|
812
|
+
) : null}
|
|
813
|
+
|
|
814
|
+
{property.expression ? (
|
|
815
|
+
<div className="max-w-[60ch] pl-6">
|
|
816
|
+
<code className="block truncate rounded-md bg-muted/60 px-2 py-1 font-mono text-[11px] text-muted-foreground">
|
|
817
|
+
{property.expression}
|
|
818
|
+
</code>
|
|
819
|
+
</div>
|
|
820
|
+
) : null}
|
|
821
|
+
|
|
822
|
+
{property.description ? (
|
|
823
|
+
<div className="max-w-[52ch] pl-6">
|
|
824
|
+
<p className="text-[13px] leading-5 text-muted-foreground">{property.description}</p>
|
|
825
|
+
</div>
|
|
826
|
+
) : null}
|
|
827
|
+
</div>
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function RelationsList({ properties }: { properties: DatabaseProperty[] }) {
|
|
832
|
+
if (properties.length === 0) {
|
|
833
|
+
return <SectionEmpty label="No relations defined." />;
|
|
834
|
+
}
|
|
835
|
+
return (
|
|
836
|
+
<div className="flex flex-col">
|
|
837
|
+
{properties.map((prop, index) => (
|
|
838
|
+
<RelationRow key={prop.id} property={prop} striped={index % 2 === 1} />
|
|
839
|
+
))}
|
|
840
|
+
</div>
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function RelationRow({ property, striped }: { property: DatabaseProperty; striped: boolean }) {
|
|
845
|
+
const target = property.relation_target;
|
|
846
|
+
return (
|
|
847
|
+
<div
|
|
848
|
+
className={cn(
|
|
849
|
+
'flex flex-col gap-1.5 rounded-lg px-4 py-3.5',
|
|
850
|
+
striped && 'bg-muted/40',
|
|
851
|
+
)}
|
|
852
|
+
>
|
|
853
|
+
<div className="flex items-center gap-2.5">
|
|
854
|
+
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
855
|
+
<span className="text-[13.5px] font-semibold tracking-[-0.005em] text-foreground">
|
|
856
|
+
{property.name ?? 'Untitled'}
|
|
857
|
+
</span>
|
|
858
|
+
<span className="flex-1" />
|
|
859
|
+
<span className="text-[11px] text-muted-foreground">Relation</span>
|
|
860
|
+
</div>
|
|
861
|
+
<div className="flex items-center gap-1.5 pl-6">
|
|
862
|
+
<span className="text-[11px] text-muted-foreground">→</span>
|
|
863
|
+
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-0.5 text-[11.5px] font-medium text-foreground">
|
|
864
|
+
<Database className="h-3 w-3 text-muted-foreground" />
|
|
865
|
+
{target?.database_name ?? target?.database_slug ?? 'Unknown database'}
|
|
866
|
+
</span>
|
|
867
|
+
</div>
|
|
868
|
+
{property.description ? (
|
|
869
|
+
<div className="max-w-[52ch] pl-6">
|
|
870
|
+
<p className="text-[13px] leading-5 text-muted-foreground">{property.description}</p>
|
|
871
|
+
</div>
|
|
872
|
+
) : null}
|
|
873
|
+
</div>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function DocumentsTable({
|
|
878
|
+
database,
|
|
879
|
+
documents,
|
|
880
|
+
loading,
|
|
881
|
+
error,
|
|
882
|
+
expectedCount,
|
|
883
|
+
}: {
|
|
884
|
+
database: DatabaseDetail;
|
|
885
|
+
documents: DatabaseDocument[];
|
|
886
|
+
loading: boolean;
|
|
887
|
+
error: string | null;
|
|
888
|
+
expectedCount: number;
|
|
889
|
+
}) {
|
|
890
|
+
const properties = (database.schema?.properties ?? []).filter((property) => property.type !== 'title');
|
|
891
|
+
|
|
892
|
+
if (loading && documents.length === 0) {
|
|
893
|
+
return (
|
|
894
|
+
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
|
895
|
+
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
896
|
+
<p className="text-[12.5px] text-muted-foreground">Loading documents</p>
|
|
897
|
+
</div>
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (error) {
|
|
902
|
+
return (
|
|
903
|
+
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
|
904
|
+
<AlertCircle className="h-5 w-5 text-destructive" />
|
|
905
|
+
<p className="text-sm font-medium text-foreground">Couldn't load documents</p>
|
|
906
|
+
<p className="max-w-[44ch] text-[12.5px] text-muted-foreground">{error}</p>
|
|
907
|
+
</div>
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (documents.length === 0) {
|
|
912
|
+
return <DocumentsEmpty count={expectedCount} />;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return (
|
|
916
|
+
<div className="flex flex-col gap-2">
|
|
917
|
+
<div className="flex items-center justify-between gap-3 px-1">
|
|
918
|
+
<p className="text-[12.5px] text-muted-foreground">
|
|
919
|
+
{compactNumber(documents.length)} loaded
|
|
920
|
+
</p>
|
|
921
|
+
{loading ? (
|
|
922
|
+
<div className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
|
|
923
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
924
|
+
<span>Loading more</span>
|
|
925
|
+
</div>
|
|
926
|
+
) : null}
|
|
927
|
+
</div>
|
|
928
|
+
<div className="rounded-md border border-border bg-card">
|
|
929
|
+
<Table>
|
|
930
|
+
<TableHeader>
|
|
931
|
+
<TableRow>
|
|
932
|
+
<TableHead className="min-w-[260px]">Document</TableHead>
|
|
933
|
+
<TableHead className="min-w-[150px]">Created</TableHead>
|
|
934
|
+
<TableHead className="min-w-[150px]">Last edited</TableHead>
|
|
935
|
+
{properties.map((property) => (
|
|
936
|
+
<TableHead key={property.id} className="min-w-[160px]">
|
|
937
|
+
{property.name ?? property.id}
|
|
938
|
+
</TableHead>
|
|
939
|
+
))}
|
|
940
|
+
</TableRow>
|
|
941
|
+
</TableHeader>
|
|
942
|
+
<TableBody>
|
|
943
|
+
{documents.map((document) => (
|
|
944
|
+
<TableRow key={document.id}>
|
|
945
|
+
<TableCell className="min-w-[260px]">
|
|
946
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
947
|
+
<span className="truncate font-medium text-foreground">
|
|
948
|
+
{document.title || 'Untitled document'}
|
|
949
|
+
</span>
|
|
950
|
+
<span className="truncate font-mono text-[11px] text-muted-foreground">
|
|
951
|
+
{document.id}
|
|
952
|
+
</span>
|
|
953
|
+
</div>
|
|
954
|
+
</TableCell>
|
|
955
|
+
<TableCell className="whitespace-nowrap text-muted-foreground">
|
|
956
|
+
{formatDateTime(document.created_time)}
|
|
957
|
+
</TableCell>
|
|
958
|
+
<TableCell className="whitespace-nowrap text-muted-foreground">
|
|
959
|
+
{formatDateTime(document.last_edited_time)}
|
|
960
|
+
</TableCell>
|
|
961
|
+
{properties.map((property) => {
|
|
962
|
+
const propertyName = property.name ?? property.id;
|
|
963
|
+
return (
|
|
964
|
+
<TableCell key={`${document.id}-${property.id}`} className="max-w-[260px]">
|
|
965
|
+
<PropertyValue
|
|
966
|
+
property={property}
|
|
967
|
+
value={document.properties?.[propertyName]}
|
|
968
|
+
/>
|
|
969
|
+
</TableCell>
|
|
970
|
+
);
|
|
971
|
+
})}
|
|
972
|
+
</TableRow>
|
|
973
|
+
))}
|
|
974
|
+
</TableBody>
|
|
975
|
+
</Table>
|
|
976
|
+
</div>
|
|
977
|
+
</div>
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function DocumentsEmpty({ count }: { count: number }) {
|
|
982
|
+
return (
|
|
983
|
+
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
|
984
|
+
<Database className="h-8 w-8 text-muted-foreground/25" strokeWidth={1.4} />
|
|
985
|
+
<p className="text-sm font-medium text-foreground">{compactNumber(count)} documents</p>
|
|
986
|
+
</div>
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function PropertyValue({
|
|
991
|
+
property,
|
|
992
|
+
value,
|
|
993
|
+
}: {
|
|
994
|
+
property: DatabaseProperty;
|
|
995
|
+
value: DocumentPropertyValue | undefined;
|
|
996
|
+
}) {
|
|
997
|
+
if (!value) return <MutedDash />;
|
|
998
|
+
|
|
999
|
+
switch (property.type) {
|
|
1000
|
+
case 'title':
|
|
1001
|
+
return <TextValue value={textFromRichText(value.title)} />;
|
|
1002
|
+
case 'rich_text':
|
|
1003
|
+
return <TextValue value={textFromRichText(value.rich_text)} />;
|
|
1004
|
+
case 'number':
|
|
1005
|
+
return <TextValue value={typeof value.number === 'number' ? compactNumber(value.number) : ''} />;
|
|
1006
|
+
case 'select':
|
|
1007
|
+
case 'status': {
|
|
1008
|
+
const selected = value[property.type];
|
|
1009
|
+
if (!selected || typeof selected !== 'object') return <MutedDash />;
|
|
1010
|
+
const option = selected as { name?: string | null; color?: string | null };
|
|
1011
|
+
if (!option.name) return <MutedDash />;
|
|
1012
|
+
return (
|
|
1013
|
+
<Badge variant="secondary" className={cn('rounded-sm px-1.5 py-px', selectTint(option.color))}>
|
|
1014
|
+
{option.name}
|
|
1015
|
+
</Badge>
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
case 'multi_select': {
|
|
1019
|
+
const options = Array.isArray(value.multi_select) ? value.multi_select : [];
|
|
1020
|
+
if (options.length === 0) return <MutedDash />;
|
|
1021
|
+
return (
|
|
1022
|
+
<div className="flex max-w-[240px] flex-wrap gap-1">
|
|
1023
|
+
{options.map((option, index) => {
|
|
1024
|
+
const item = option && typeof option === 'object'
|
|
1025
|
+
? option as { id?: string | null; name?: string | null; color?: string | null }
|
|
1026
|
+
: null;
|
|
1027
|
+
return (
|
|
1028
|
+
<Badge
|
|
1029
|
+
key={item?.id ?? item?.name ?? index}
|
|
1030
|
+
variant="secondary"
|
|
1031
|
+
className={cn('rounded-sm px-1.5 py-px', selectTint(item?.color))}
|
|
1032
|
+
>
|
|
1033
|
+
{item?.name ?? 'Untitled'}
|
|
1034
|
+
</Badge>
|
|
1035
|
+
);
|
|
1036
|
+
})}
|
|
1037
|
+
</div>
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
case 'checkbox':
|
|
1041
|
+
return <TextValue value={value.checkbox ? 'Yes' : 'No'} />;
|
|
1042
|
+
case 'date': {
|
|
1043
|
+
const date = value.date;
|
|
1044
|
+
if (!date || typeof date !== 'object') return <MutedDash />;
|
|
1045
|
+
const range = date as { start?: string | null; end?: string | null };
|
|
1046
|
+
const start = formatDate(range.start);
|
|
1047
|
+
const end = range.end ? formatDate(range.end) : null;
|
|
1048
|
+
return <TextValue value={end ? `${start} – ${end}` : start} />;
|
|
1049
|
+
}
|
|
1050
|
+
case 'url':
|
|
1051
|
+
case 'email':
|
|
1052
|
+
case 'phone_number':
|
|
1053
|
+
return <TextValue value={typeof value[property.type] === 'string' ? value[property.type] as string : ''} />;
|
|
1054
|
+
case 'relation':
|
|
1055
|
+
return <TextValue value={relationLabel(value.relation, 'linked record')} />;
|
|
1056
|
+
case 'people':
|
|
1057
|
+
return <TextValue value={relationLabel(value.people, 'person')} />;
|
|
1058
|
+
case 'files':
|
|
1059
|
+
return <TextValue value={relationLabel(value.files, 'file')} />;
|
|
1060
|
+
default:
|
|
1061
|
+
return <TextValue value={stringifyFallback(value)} />;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function TextValue({ value }: { value: string }) {
|
|
1066
|
+
if (!value) return <MutedDash />;
|
|
1067
|
+
return <span className="block truncate text-[12.5px] text-foreground">{value}</span>;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function MutedDash() {
|
|
1071
|
+
return <span className="text-muted-foreground/60">—</span>;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function stringifyFallback(value: unknown): string {
|
|
1075
|
+
if (value == null) return '';
|
|
1076
|
+
if (typeof value === 'string') return value;
|
|
1077
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
1078
|
+
try {
|
|
1079
|
+
return JSON.stringify(value);
|
|
1080
|
+
} catch {
|
|
1081
|
+
return '';
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function SectionEmpty({ label }: { label: string }) {
|
|
1086
|
+
return (
|
|
1087
|
+
<div className="flex flex-col items-center justify-center gap-1 py-12 text-center">
|
|
1088
|
+
<p className="text-[12.5px] text-muted-foreground">{label}</p>
|
|
1089
|
+
</div>
|
|
1090
|
+
);
|
|
1091
|
+
}
|