@notis_ai/cli 0.2.0-beta.21.1 → 0.2.0-beta.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -112
- 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 +8 -3
- package/skills/notis-apps/SKILL.md +168 -0
- package/skills/notis-apps/cli.md +208 -0
- package/skills/notis-cli/SKILL.md +263 -0
- package/skills/notis-query/cli.md +40 -0
- package/src/cli.js +1 -1
- package/src/command-specs/apps.js +962 -59
- package/src/command-specs/helpers.js +6 -41
- package/src/command-specs/index.js +1 -7
- package/src/command-specs/meta.js +7 -6
- package/src/command-specs/tools.js +29 -34
- package/src/runtime/agent-browser.js +192 -0
- package/src/runtime/app-boundary-validator.js +132 -0
- package/src/runtime/app-dev-server.js +605 -0
- package/src/runtime/app-dev-sessions.js +87 -0
- package/src/runtime/app-platform.js +725 -38
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/output.js +2 -2
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +36 -5
- package/src/runtime/transport.js +131 -6
- package/template/.harness/index.html.tmpl +211 -0
- package/template/app/layout.tsx +1 -2
- 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 +25 -6
- package/template/package.json +3 -4
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/{notis-sdk → sdk}/src/config.ts +25 -2
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +6 -7
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +4 -4
- package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/sdk/src/index.ts +46 -0
- package/template/packages/sdk/src/provider.tsx +43 -0
- package/template/packages/sdk/src/runtime.ts +80 -0
- package/template/packages/sdk/src/styles.css +38 -0
- package/src/command-specs/auth.js +0 -178
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -336
- 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/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -58
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -165
- package/template/packages/notis-sdk/src/styles.css +0 -123
- /package/template/packages/{notis-sdk → sdk}/package.json +0 -0
- /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/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
|
@@ -1,336 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local preview server for built Notis app bundles.
|
|
3
|
-
*
|
|
4
|
-
* Serves a single index.html that loads the app bundle via <script type="module">.
|
|
5
|
-
* Injects mock window.__NOTIS_RUNTIME__ before the bundle loads. Route switching
|
|
6
|
-
* uses pathname navigation between route pages.
|
|
7
|
-
*
|
|
8
|
-
* Usage: `notis apps preview [dir]` starts this server on localhost:8787.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { createServer } from 'node:http';
|
|
12
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
-
import { extname, resolve, sep } from 'node:path';
|
|
14
|
-
|
|
15
|
-
import { readManifest } from './app-platform.js';
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Static file serving
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
function contentTypeFor(path) {
|
|
22
|
-
const types = {
|
|
23
|
-
'.html': 'text/html; charset=utf-8',
|
|
24
|
-
'.css': 'text/css; charset=utf-8',
|
|
25
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
26
|
-
'.json': 'application/json; charset=utf-8',
|
|
27
|
-
'.svg': 'image/svg+xml',
|
|
28
|
-
'.png': 'image/png',
|
|
29
|
-
'.jpg': 'image/jpeg',
|
|
30
|
-
'.jpeg': 'image/jpeg',
|
|
31
|
-
'.ico': 'image/x-icon',
|
|
32
|
-
'.woff': 'font/woff',
|
|
33
|
-
'.woff2': 'font/woff2',
|
|
34
|
-
};
|
|
35
|
-
return types[extname(path)] || 'application/octet-stream';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Mock runtime generation
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
function buildSeedDocuments(databases) {
|
|
43
|
-
const state = {};
|
|
44
|
-
for (const slug of databases) {
|
|
45
|
-
state[slug] = Array.from({ length: 3 }, (_, i) => {
|
|
46
|
-
const title = `${slug} ${i + 1}`;
|
|
47
|
-
const content = `This is a preview note for ${title}.`;
|
|
48
|
-
return {
|
|
49
|
-
id: `${slug}-seed-${i + 1}`,
|
|
50
|
-
databaseSlug: slug,
|
|
51
|
-
title,
|
|
52
|
-
properties: {},
|
|
53
|
-
icon: null,
|
|
54
|
-
contentBlocknote: [{ type: 'paragraph', content: [{ type: 'text', text: content }] }],
|
|
55
|
-
contentMarkdown: content,
|
|
56
|
-
plainText: content,
|
|
57
|
-
};
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
return state;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function escapeHtml(value) {
|
|
64
|
-
return String(value)
|
|
65
|
-
.replaceAll('&', '&')
|
|
66
|
-
.replaceAll('<', '<')
|
|
67
|
-
.replaceAll('>', '>')
|
|
68
|
-
.replaceAll('"', '"')
|
|
69
|
-
.replaceAll("'", ''');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function sanitizeBundlePath(value, fallback) {
|
|
73
|
-
if (typeof value !== 'string') return fallback;
|
|
74
|
-
const normalized = value.trim().replaceAll('\\', '/').replace(/^\/+/, '');
|
|
75
|
-
if (!normalized || normalized.includes('..')) return fallback;
|
|
76
|
-
return /^[A-Za-z0-9._/-]+$/.test(normalized) ? normalized : fallback;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function serializeForInlineScript(value) {
|
|
80
|
-
return JSON.stringify(value).replaceAll('</', '<\\/').replaceAll('<!--', '<\\!--');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function buildPreviewHtml({ manifest, route, databases }) {
|
|
84
|
-
const app = manifest.app;
|
|
85
|
-
const seedState = buildSeedDocuments(databases);
|
|
86
|
-
const bundleJs = sanitizeBundlePath(manifest.bundle?.js, 'bundle/app.js');
|
|
87
|
-
const bundleCss = sanitizeBundlePath(manifest.bundle?.css, 'bundle/app.css');
|
|
88
|
-
const exportName = typeof route.export_name === 'string' && route.export_name ? route.export_name : 'index';
|
|
89
|
-
const pageTitle = escapeHtml(`${app.name} - ${route.name}`);
|
|
90
|
-
const bundleCssHref = escapeHtml(`/${bundleCss}`);
|
|
91
|
-
const bundleJsPathLiteral = JSON.stringify(`/${bundleJs}`);
|
|
92
|
-
const exportNameLiteral = JSON.stringify(exportName);
|
|
93
|
-
|
|
94
|
-
return `<!DOCTYPE html>
|
|
95
|
-
<html lang="en">
|
|
96
|
-
<head>
|
|
97
|
-
<meta charset="UTF-8" />
|
|
98
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
99
|
-
<title>${pageTitle}</title>
|
|
100
|
-
<link rel="stylesheet" href="${bundleCssHref}" />
|
|
101
|
-
<script>
|
|
102
|
-
(function() {
|
|
103
|
-
var DB = ${serializeForInlineScript(databases.map(function(slug) {
|
|
104
|
-
return { slug: slug, title: slug, properties: [] };
|
|
105
|
-
}))};
|
|
106
|
-
var STATE = ${serializeForInlineScript(seedState)};
|
|
107
|
-
var COLLECTION = ${serializeForInlineScript(route.collection || null)};
|
|
108
|
-
|
|
109
|
-
function titleProp(slug) {
|
|
110
|
-
var db = DB.find(function(d) { return d.slug === slug; });
|
|
111
|
-
return db && db.properties ? (db.properties.find(function(p) { return p.type === 'title'; }) || {}).name || 'title' : 'title';
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function dbSlug(v) { return v || (COLLECTION && COLLECTION.database) || null; }
|
|
115
|
-
|
|
116
|
-
window.__NOTIS_RUNTIME__ = {
|
|
117
|
-
app: ${serializeForInlineScript(app)},
|
|
118
|
-
route: ${serializeForInlineScript(route)},
|
|
119
|
-
databases: DB,
|
|
120
|
-
|
|
121
|
-
navigate: function(payload) {
|
|
122
|
-
console.log('[notis-preview] Navigate:', payload);
|
|
123
|
-
if (payload.kind === 'route' && payload.path) {
|
|
124
|
-
window.location.assign(payload.path);
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
listTools: function() {
|
|
129
|
-
return Promise.resolve(${serializeForInlineScript((manifest.tools || []).map((name) => ({ name })))});
|
|
130
|
-
},
|
|
131
|
-
|
|
132
|
-
callTool: function(name, args) {
|
|
133
|
-
console.log('[notis-preview] Tool call:', name, args);
|
|
134
|
-
return Promise.reject(new Error('Tool calls are not available in local preview. Deploy the app to use real tools.'));
|
|
135
|
-
},
|
|
136
|
-
|
|
137
|
-
queryDatabase: function(args) {
|
|
138
|
-
var slug = dbSlug(args && args.databaseSlug);
|
|
139
|
-
var docs = (STATE[slug] || []).slice(args && args.offset || 0);
|
|
140
|
-
return Promise.resolve({ documents: docs });
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
getDocument: function(args) {
|
|
144
|
-
var id = args && args.documentId;
|
|
145
|
-
for (var slug in STATE) {
|
|
146
|
-
var match = STATE[slug].find(function(d) { return d.id === id; });
|
|
147
|
-
if (match) return Promise.resolve(match);
|
|
148
|
-
}
|
|
149
|
-
return Promise.reject(new Error('Document not found in preview.'));
|
|
150
|
-
},
|
|
151
|
-
|
|
152
|
-
upsertDocument: function(args) {
|
|
153
|
-
var slug = dbSlug(args && args.databaseSlug);
|
|
154
|
-
if (!slug) return Promise.reject(new Error('No database for upsert.'));
|
|
155
|
-
var docs = STATE[slug] || [];
|
|
156
|
-
var tp = titleProp(slug);
|
|
157
|
-
var existing = docs.find(function(d) { return d.id === (args && args.documentId); });
|
|
158
|
-
var props = Object.assign({}, existing && existing.properties, args && args.properties);
|
|
159
|
-
if (args && args.title) props[tp] = args.title;
|
|
160
|
-
var blocks = Array.isArray(args && args.contentBlocknote)
|
|
161
|
-
? JSON.parse(JSON.stringify(args.contentBlocknote))
|
|
162
|
-
: existing && existing.contentBlocknote
|
|
163
|
-
? JSON.parse(JSON.stringify(existing.contentBlocknote))
|
|
164
|
-
: [{ type: 'paragraph' }];
|
|
165
|
-
var plainText = blocks
|
|
166
|
-
.map(function(block) {
|
|
167
|
-
if (typeof block.content === 'string') return block.content;
|
|
168
|
-
if (!Array.isArray(block.content)) return '';
|
|
169
|
-
return block.content
|
|
170
|
-
.map(function(inline) { return inline && typeof inline.text === 'string' ? inline.text : ''; })
|
|
171
|
-
.join('');
|
|
172
|
-
})
|
|
173
|
-
.filter(Boolean)
|
|
174
|
-
.join('\\n')
|
|
175
|
-
.trim();
|
|
176
|
-
var doc = {
|
|
177
|
-
id: existing ? existing.id : slug + '-' + Date.now(),
|
|
178
|
-
databaseSlug: slug,
|
|
179
|
-
title: props[tp] || 'Untitled',
|
|
180
|
-
properties: props,
|
|
181
|
-
icon: null,
|
|
182
|
-
contentBlocknote: blocks,
|
|
183
|
-
contentMarkdown: plainText,
|
|
184
|
-
plainText: plainText
|
|
185
|
-
};
|
|
186
|
-
if (existing) {
|
|
187
|
-
var idx = docs.indexOf(existing);
|
|
188
|
-
docs[idx] = doc;
|
|
189
|
-
} else {
|
|
190
|
-
docs.unshift(doc);
|
|
191
|
-
}
|
|
192
|
-
STATE[slug] = docs;
|
|
193
|
-
return Promise.resolve({ status: 'success', document: doc });
|
|
194
|
-
},
|
|
195
|
-
|
|
196
|
-
listCollectionItems: function(args) {
|
|
197
|
-
var slug = dbSlug(args && args.databaseSlug);
|
|
198
|
-
if (!slug) return Promise.resolve({ items: [] });
|
|
199
|
-
var tp = (args && args.titleProperty) || titleProp(slug);
|
|
200
|
-
var docs = (STATE[slug] || []).slice(0, (args && args.pageSize) || 100);
|
|
201
|
-
return Promise.resolve({
|
|
202
|
-
items: docs.map(function(d) {
|
|
203
|
-
return { id: d.id, title: (d.properties && d.properties[tp]) || d.title || 'Untitled', icon: d.icon };
|
|
204
|
-
})
|
|
205
|
-
});
|
|
206
|
-
},
|
|
207
|
-
|
|
208
|
-
request: function() {
|
|
209
|
-
return Promise.reject(new Error('Backend requests are not available in local preview.'));
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
})();
|
|
213
|
-
</script>
|
|
214
|
-
</head>
|
|
215
|
-
<body class="min-h-screen bg-background text-foreground antialiased">
|
|
216
|
-
<div id="notis-app-root"></div>
|
|
217
|
-
<script type="importmap">
|
|
218
|
-
{
|
|
219
|
-
"imports": {
|
|
220
|
-
"react": "https://esm.sh/react@19",
|
|
221
|
-
"react-dom": "https://esm.sh/react-dom@19?external=react",
|
|
222
|
-
"react-dom/client": "https://esm.sh/react-dom@19/client?external=react",
|
|
223
|
-
"react/jsx-runtime": "https://esm.sh/react@19/jsx-runtime"
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
</script>
|
|
227
|
-
<script type="module">
|
|
228
|
-
import React from 'react';
|
|
229
|
-
import { createRoot } from 'react-dom/client';
|
|
230
|
-
import * as AppBundle from ${bundleJsPathLiteral};
|
|
231
|
-
|
|
232
|
-
// Try to render: AppShell wrapping the route component, or just the route component
|
|
233
|
-
const exportName = ${exportNameLiteral};
|
|
234
|
-
const RouteComponent = AppBundle[exportName] || AppBundle['default'];
|
|
235
|
-
const AppShell = AppBundle['__AppShell'];
|
|
236
|
-
|
|
237
|
-
if (RouteComponent) {
|
|
238
|
-
const root = document.getElementById('notis-app-root');
|
|
239
|
-
if (root) {
|
|
240
|
-
const reactRoot = createRoot(root);
|
|
241
|
-
if (AppShell) {
|
|
242
|
-
reactRoot.render(React.createElement(AppShell, null, React.createElement(RouteComponent)));
|
|
243
|
-
} else {
|
|
244
|
-
reactRoot.render(React.createElement(RouteComponent));
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
} else {
|
|
248
|
-
document.getElementById('notis-app-root').innerHTML =
|
|
249
|
-
'<p style="padding:2rem;color:red;">No component found for export "' + exportName + '". Available exports: ' +
|
|
250
|
-
Object.keys(AppBundle).join(', ') + '</p>';
|
|
251
|
-
}
|
|
252
|
-
</script>
|
|
253
|
-
</body>
|
|
254
|
-
</html>`;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
// Server
|
|
259
|
-
// ---------------------------------------------------------------------------
|
|
260
|
-
|
|
261
|
-
function normalizePathname(pathname) {
|
|
262
|
-
if (!pathname || pathname === '/') return '/';
|
|
263
|
-
return pathname.endsWith('/') ? pathname.slice(0, -1) || '/' : pathname;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function resolveSafePath(baseDir, pathname) {
|
|
267
|
-
const normalizedBaseDir = resolve(baseDir);
|
|
268
|
-
const resolvedPath = resolve(normalizedBaseDir, pathname.replace(/^\/+/, ''));
|
|
269
|
-
if (resolvedPath === normalizedBaseDir || resolvedPath.startsWith(`${normalizedBaseDir}${sep}`)) {
|
|
270
|
-
return resolvedPath;
|
|
271
|
-
}
|
|
272
|
-
return null;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export async function startPreviewServer({ projectDir, port }) {
|
|
276
|
-
const manifest = readManifest(projectDir);
|
|
277
|
-
const outputDir = resolve(projectDir, '.notis/output');
|
|
278
|
-
const bundleDir = resolve(projectDir, '.notis/output/bundle');
|
|
279
|
-
const databases = (manifest.databases || []).filter((slug) => typeof slug === 'string' && slug);
|
|
280
|
-
const routesByPath = new Map((manifest.routes || []).map((r) => [normalizePathname(r.path), r]));
|
|
281
|
-
const defaultRoute = (manifest.routes || []).find((r) => r.default) || manifest.routes?.[0];
|
|
282
|
-
|
|
283
|
-
if (!defaultRoute) {
|
|
284
|
-
throw new Error('Manifest contains no routes.');
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const server = createServer((request, response) => {
|
|
288
|
-
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
|
289
|
-
const pathname = normalizePathname(url.pathname);
|
|
290
|
-
|
|
291
|
-
// Serve bundle files directly
|
|
292
|
-
if (pathname.startsWith('/bundle/')) {
|
|
293
|
-
const filePath = resolveSafePath(outputDir, pathname);
|
|
294
|
-
if (filePath && existsSync(filePath)) {
|
|
295
|
-
response.writeHead(200, { 'Content-Type': contentTypeFor(filePath), 'Cache-Control': 'no-store' });
|
|
296
|
-
response.end(readFileSync(filePath));
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Redirect / to default route if it's not /
|
|
302
|
-
if (pathname === '/' && defaultRoute.path !== '/') {
|
|
303
|
-
response.writeHead(302, { Location: defaultRoute.path });
|
|
304
|
-
response.end();
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Serve route HTML with runtime injection
|
|
309
|
-
const route = routesByPath.get(pathname) || (pathname === '/' ? defaultRoute : null);
|
|
310
|
-
if (route) {
|
|
311
|
-
const html = buildPreviewHtml({ manifest, route, databases });
|
|
312
|
-
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
313
|
-
response.end(html);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Serve static files from bundle dir
|
|
318
|
-
const filePath = resolveSafePath(bundleDir, pathname);
|
|
319
|
-
if (filePath && existsSync(filePath)) {
|
|
320
|
-
response.writeHead(200, { 'Content-Type': contentTypeFor(filePath), 'Cache-Control': 'no-store' });
|
|
321
|
-
response.end(readFileSync(filePath));
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
response.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
326
|
-
response.end('Not found');
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
await new Promise((resolveP, rejectP) => {
|
|
330
|
-
server.on('error', rejectP);
|
|
331
|
-
server.listen(port, '127.0.0.1', resolveP);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
// Keep running until interrupted
|
|
335
|
-
await new Promise(() => {});
|
|
336
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Property extraction helpers for Notis DocumentRecord properties.
|
|
3
|
-
*
|
|
4
|
-
* Document properties come back in Notion-style nested shapes. These helpers
|
|
5
|
-
* normalize them into simple JS values regardless of the shape.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { DocumentRecord } from './runtime';
|
|
9
|
-
|
|
10
|
-
type Obj = Record<string, unknown>;
|
|
11
|
-
|
|
12
|
-
/** Extract a string from a title, rich_text, or plain text property. */
|
|
13
|
-
export function extractText(val: unknown): string {
|
|
14
|
-
if (typeof val === 'string') return val;
|
|
15
|
-
if (val && typeof val === 'object') {
|
|
16
|
-
const obj = val as Obj;
|
|
17
|
-
if (typeof obj.content === 'string') return obj.content;
|
|
18
|
-
if (typeof obj.plain_text === 'string') return obj.plain_text;
|
|
19
|
-
if (Array.isArray(obj.title)) {
|
|
20
|
-
return obj.title.map((t: Obj) => extractText(t?.text || t)).join('');
|
|
21
|
-
}
|
|
22
|
-
if (Array.isArray(obj.rich_text)) {
|
|
23
|
-
return obj.rich_text.map((t: Obj) => extractText(t?.text || t)).join('');
|
|
24
|
-
}
|
|
25
|
-
if (obj.text) return extractText(obj.text);
|
|
26
|
-
}
|
|
27
|
-
return '';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Extract a string from a select or status property. */
|
|
31
|
-
export function extractSelect(val: unknown): string {
|
|
32
|
-
if (typeof val === 'string') return val;
|
|
33
|
-
if (val && typeof val === 'object') {
|
|
34
|
-
const obj = val as Obj;
|
|
35
|
-
if (obj.select && typeof obj.select === 'object') {
|
|
36
|
-
return (obj.select as Obj).name as string || '';
|
|
37
|
-
}
|
|
38
|
-
if (obj.status && typeof obj.status === 'object') {
|
|
39
|
-
return (obj.status as Obj).name as string || '';
|
|
40
|
-
}
|
|
41
|
-
if (typeof obj.name === 'string') return obj.name;
|
|
42
|
-
}
|
|
43
|
-
return '';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Extract a date string from a date property. */
|
|
47
|
-
export function extractDate(val: unknown): string {
|
|
48
|
-
if (typeof val === 'string') return val;
|
|
49
|
-
if (val && typeof val === 'object') {
|
|
50
|
-
const obj = val as Obj;
|
|
51
|
-
if (obj.date && typeof obj.date === 'object') {
|
|
52
|
-
return (obj.date as Obj).start as string || '';
|
|
53
|
-
}
|
|
54
|
-
if (typeof obj.start === 'string') return obj.start;
|
|
55
|
-
}
|
|
56
|
-
return '';
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Extract a number from a number property. */
|
|
60
|
-
export function extractNumber(val: unknown): number | null {
|
|
61
|
-
if (typeof val === 'number') return val;
|
|
62
|
-
if (val && typeof val === 'object') {
|
|
63
|
-
const obj = val as Obj;
|
|
64
|
-
if (typeof obj.number === 'number') return obj.number;
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Extract a boolean from a checkbox property. */
|
|
70
|
-
export function extractCheckbox(val: unknown): boolean {
|
|
71
|
-
if (typeof val === 'boolean') return val;
|
|
72
|
-
if (val && typeof val === 'object') {
|
|
73
|
-
const obj = val as Obj;
|
|
74
|
-
if (typeof obj.checkbox === 'boolean') return obj.checkbox;
|
|
75
|
-
}
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Extract multi-select values as a string array. */
|
|
80
|
-
export function extractMultiSelect(val: unknown): string[] {
|
|
81
|
-
if (Array.isArray(val)) {
|
|
82
|
-
return val
|
|
83
|
-
.map((v) => {
|
|
84
|
-
if (typeof v === 'string') return v;
|
|
85
|
-
if (v && typeof v === 'object' && typeof (v as Obj).name === 'string') return (v as Obj).name as string;
|
|
86
|
-
return '';
|
|
87
|
-
})
|
|
88
|
-
.filter(Boolean);
|
|
89
|
-
}
|
|
90
|
-
if (val && typeof val === 'object') {
|
|
91
|
-
const obj = val as Obj;
|
|
92
|
-
if (Array.isArray(obj.multi_select)) return extractMultiSelect(obj.multi_select);
|
|
93
|
-
}
|
|
94
|
-
if (typeof val === 'string' && val) return [val];
|
|
95
|
-
return [];
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Extract relation IDs from a relation property. */
|
|
99
|
-
export function extractRelation(val: unknown): string[] {
|
|
100
|
-
if (Array.isArray(val)) {
|
|
101
|
-
return val
|
|
102
|
-
.map((v) => {
|
|
103
|
-
if (typeof v === 'string') return v;
|
|
104
|
-
if (v && typeof v === 'object' && typeof (v as Obj).id === 'string') return (v as Obj).id as string;
|
|
105
|
-
return '';
|
|
106
|
-
})
|
|
107
|
-
.filter(Boolean);
|
|
108
|
-
}
|
|
109
|
-
if (val && typeof val === 'object') {
|
|
110
|
-
const obj = val as Obj;
|
|
111
|
-
if (Array.isArray(obj.relation)) return extractRelation(obj.relation);
|
|
112
|
-
}
|
|
113
|
-
if (typeof val === 'string' && val) return [val];
|
|
114
|
-
return [];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Extract display title from a document. Falls back through several property
|
|
119
|
-
* names and shapes to find a human-readable title.
|
|
120
|
-
*/
|
|
121
|
-
export function docTitle(doc: DocumentRecord): string {
|
|
122
|
-
const t = doc.title;
|
|
123
|
-
if (t && !/^[0-9a-f]{8}-[0-9a-f]{4}/.test(t)) return t;
|
|
124
|
-
const p = doc.properties || {};
|
|
125
|
-
const name = p.Name ?? p.name ?? p.Title ?? p.title;
|
|
126
|
-
if (name) {
|
|
127
|
-
const extracted = extractText(name);
|
|
128
|
-
if (extracted) return extracted;
|
|
129
|
-
}
|
|
130
|
-
return t || 'Untitled';
|
|
131
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useCallback, useState } from 'react';
|
|
4
|
-
import { useNotisRuntime } from '../provider';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Persist app-level state per user per app using localStorage.
|
|
8
|
-
*
|
|
9
|
-
* ```tsx
|
|
10
|
-
* const [order, setOrder] = useAppState<string[]>('task-order', []);
|
|
11
|
-
* ```
|
|
12
|
-
*
|
|
13
|
-
* State is scoped by `app.id` so different apps don't collide.
|
|
14
|
-
* Falls back to defaultValue when localStorage is unavailable.
|
|
15
|
-
*/
|
|
16
|
-
export function useAppState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
|
17
|
-
const runtime = useNotisRuntime();
|
|
18
|
-
const appId = runtime?.app?.id || '_unknown';
|
|
19
|
-
const storageKey = `notis-app-state:${appId}:${key}`;
|
|
20
|
-
|
|
21
|
-
const [value, setValueInternal] = useState<T>(() => {
|
|
22
|
-
if (typeof window === 'undefined') return defaultValue;
|
|
23
|
-
try {
|
|
24
|
-
const stored = localStorage.getItem(storageKey);
|
|
25
|
-
if (stored !== null) return JSON.parse(stored) as T;
|
|
26
|
-
} catch {
|
|
27
|
-
// Ignore parse errors or storage unavailable
|
|
28
|
-
}
|
|
29
|
-
return defaultValue;
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
const setValue = useCallback(
|
|
33
|
-
(newValue: T | ((prev: T) => T)) => {
|
|
34
|
-
setValueInternal((prev) => {
|
|
35
|
-
const resolved = typeof newValue === 'function'
|
|
36
|
-
? (newValue as (prev: T) => T)(prev)
|
|
37
|
-
: newValue;
|
|
38
|
-
try {
|
|
39
|
-
localStorage.setItem(storageKey, JSON.stringify(resolved));
|
|
40
|
-
} catch {
|
|
41
|
-
// Ignore quota errors
|
|
42
|
-
}
|
|
43
|
-
return resolved;
|
|
44
|
-
});
|
|
45
|
-
},
|
|
46
|
-
[storageKey],
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
return [value, setValue];
|
|
50
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
4
|
-
import { useNotisRuntime } from '../provider';
|
|
5
|
-
import type { CollectionItem } from '../runtime';
|
|
6
|
-
|
|
7
|
-
interface UseCollectionResult {
|
|
8
|
-
/** Items in the collection (from the bound database). */
|
|
9
|
-
items: CollectionItem[];
|
|
10
|
-
loading: boolean;
|
|
11
|
-
error: Error | null;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* List collection items for the current route. Only meaningful on routes
|
|
16
|
-
* that have a `collection` binding in `notis.config.ts`.
|
|
17
|
-
*
|
|
18
|
-
* ```tsx
|
|
19
|
-
* const { items, loading } = useCollectionItems();
|
|
20
|
-
* ```
|
|
21
|
-
*/
|
|
22
|
-
export function useCollectionItems(): UseCollectionResult {
|
|
23
|
-
const runtime = useNotisRuntime();
|
|
24
|
-
const [items, setItems] = useState<CollectionItem[]>([]);
|
|
25
|
-
const [loading, setLoading] = useState(true);
|
|
26
|
-
const [error, setError] = useState<Error | null>(null);
|
|
27
|
-
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
if (!runtime) {
|
|
30
|
-
setLoading(false);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
let cancelled = false;
|
|
35
|
-
setLoading(true);
|
|
36
|
-
|
|
37
|
-
runtime
|
|
38
|
-
.listCollectionItems()
|
|
39
|
-
.then((result) => {
|
|
40
|
-
if (!cancelled) {
|
|
41
|
-
setItems(result.items);
|
|
42
|
-
setLoading(false);
|
|
43
|
-
}
|
|
44
|
-
})
|
|
45
|
-
.catch((err) => {
|
|
46
|
-
if (!cancelled) {
|
|
47
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
48
|
-
setLoading(false);
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
return () => {
|
|
53
|
-
cancelled = true;
|
|
54
|
-
};
|
|
55
|
-
}, [runtime]);
|
|
56
|
-
|
|
57
|
-
return { items, loading, error };
|
|
58
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
-
import { useNotisRuntime } from '../provider';
|
|
5
|
-
import type { DocumentRecord, QueryFilter } from '../runtime';
|
|
6
|
-
|
|
7
|
-
interface UseDatabaseOptions {
|
|
8
|
-
filter?: QueryFilter;
|
|
9
|
-
pageSize?: number;
|
|
10
|
-
offset?: number;
|
|
11
|
-
/** Set to false to skip the initial fetch. */
|
|
12
|
-
enabled?: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface UseDatabaseResult {
|
|
16
|
-
documents: DocumentRecord[];
|
|
17
|
-
loading: boolean;
|
|
18
|
-
error: Error | null;
|
|
19
|
-
refetch: () => void;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Query documents from a Notis database owned by this app.
|
|
24
|
-
*
|
|
25
|
-
* ```tsx
|
|
26
|
-
* const { documents, loading } = useDatabase('transactions', {
|
|
27
|
-
* filter: { sorts: [{ property: 'date', direction: 'desc' }] },
|
|
28
|
-
* pageSize: 20,
|
|
29
|
-
* });
|
|
30
|
-
* ```
|
|
31
|
-
*/
|
|
32
|
-
export function useDatabase(
|
|
33
|
-
databaseSlug: string,
|
|
34
|
-
options: UseDatabaseOptions = {},
|
|
35
|
-
): UseDatabaseResult {
|
|
36
|
-
const runtime = useNotisRuntime();
|
|
37
|
-
const [documents, setDocuments] = useState<DocumentRecord[]>([]);
|
|
38
|
-
const [loading, setLoading] = useState(true);
|
|
39
|
-
const [error, setError] = useState<Error | null>(null);
|
|
40
|
-
const [fetchKey, setFetchKey] = useState(0);
|
|
41
|
-
|
|
42
|
-
const enabled = options.enabled !== false;
|
|
43
|
-
|
|
44
|
-
const refetch = useCallback(() => {
|
|
45
|
-
setFetchKey((k) => k + 1);
|
|
46
|
-
}, []);
|
|
47
|
-
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
if (!runtime || !enabled) {
|
|
50
|
-
setLoading(false);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
let cancelled = false;
|
|
55
|
-
setLoading(true);
|
|
56
|
-
setError(null);
|
|
57
|
-
|
|
58
|
-
runtime
|
|
59
|
-
.queryDatabase({
|
|
60
|
-
databaseSlug,
|
|
61
|
-
query: {
|
|
62
|
-
...options.filter,
|
|
63
|
-
page_size: options.pageSize,
|
|
64
|
-
},
|
|
65
|
-
offset: options.offset,
|
|
66
|
-
})
|
|
67
|
-
.then((result) => {
|
|
68
|
-
if (!cancelled) {
|
|
69
|
-
setDocuments(result.documents);
|
|
70
|
-
setLoading(false);
|
|
71
|
-
}
|
|
72
|
-
})
|
|
73
|
-
.catch((err) => {
|
|
74
|
-
if (!cancelled) {
|
|
75
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
76
|
-
setLoading(false);
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
return () => {
|
|
81
|
-
cancelled = true;
|
|
82
|
-
};
|
|
83
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
-
}, [runtime, databaseSlug, fetchKey, enabled]);
|
|
85
|
-
|
|
86
|
-
return { documents, loading, error, refetch };
|
|
87
|
-
}
|