@notis_ai/cli 0.2.0-beta.155.1 → 0.2.0-beta.157.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 +11 -45
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +5180 -7281
- package/dist/base-skills/notis-apps/SKILL.md +141 -224
- package/dist/base-skills/notis-cli/SKILL.md +64 -131
- package/package.json +1 -2
- package/skills/notis-apps/cli.md +34 -95
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
- package/src/command-specs/apps.js +326 -1562
- package/src/runtime/agent-browser.js +169 -1
- package/src/runtime/app-boundary-validator.js +221 -0
- package/src/runtime/app-platform.js +359 -233
- package/src/runtime/app-test-server.js +292 -0
- package/template/app/page.tsx +47 -45
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +0 -1
- package/template/package.json +2 -2
- package/template/packages/sdk/package.json +1 -2
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +62 -8
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/config.ts +0 -2
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
- package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
- package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
- package/template/packages/sdk/src/index.ts +8 -0
- package/template/packages/sdk/src/interactions.ts +2 -1
- package/template/packages/sdk/src/queryCache.ts +162 -0
- package/template/packages/sdk/src/runtime.ts +5 -0
- package/template/packages/sdk/src/styles.css +28 -1
- package/src/runtime/app-dev-build-supervisor.js +0 -47
- package/src/runtime/app-dev-build.js +0 -41
- package/src/runtime/app-dev-consumers.js +0 -154
- package/src/runtime/app-dev-host-lock.js +0 -80
- package/src/runtime/app-dev-process-identity.js +0 -111
- package/src/runtime/app-dev-roots.js +0 -284
- package/src/runtime/app-dev-server.js +0 -1136
- package/src/runtime/app-dev-sessions.js +0 -185
- package/src/runtime/cli-mode.generated.js +0 -5
- package/src/runtime/cli-mode.js +0 -34
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/** Temporary, explicit app verification/screenshot server. Never mounts a Workspace app. */
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { exportNameFromPath, getBundleDir, loadAppConfig, readManifest } from './app-platform.js';
|
|
7
|
+
|
|
8
|
+
const CONTENT_TYPES = { '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.map': 'application/json; charset=utf-8' };
|
|
9
|
+
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
10
|
+
const REPO_ROOT = resolve(CLI_ROOT, '../..');
|
|
11
|
+
const HARNESS_TEMPLATE_PATH = join(CLI_ROOT, 'template', '.harness', 'index.html.tmpl');
|
|
12
|
+
const FALLBACK_REACT_VERSION = '19.0.0';
|
|
13
|
+
|
|
14
|
+
function extFor(pathname) {
|
|
15
|
+
const idx = pathname.lastIndexOf('.');
|
|
16
|
+
return idx === -1 ? '' : pathname.slice(idx);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isAllowedOrigin(origin) {
|
|
20
|
+
if (!origin) return true;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(origin);
|
|
23
|
+
if (parsed.protocol === 'notis-app:') return true;
|
|
24
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
|
25
|
+
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function corsHeaders(origin) {
|
|
32
|
+
const allowOrigin = origin && isAllowedOrigin(origin) ? origin : '*';
|
|
33
|
+
return {
|
|
34
|
+
'Access-Control-Allow-Origin': allowOrigin,
|
|
35
|
+
'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
|
|
36
|
+
'Access-Control-Allow-Headers': 'Content-Type, Cache-Control',
|
|
37
|
+
'Cache-Control': 'no-store',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeJoin(baseDir, relPath) {
|
|
42
|
+
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
43
|
+
if (normalized.includes('..')) return null;
|
|
44
|
+
return join(baseDir, normalized);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scriptJson(value) {
|
|
48
|
+
return JSON.stringify(value)
|
|
49
|
+
.replace(/</g, '\\u003c')
|
|
50
|
+
.replace(/\u2028/g, '\\u2028')
|
|
51
|
+
.replace(/\u2029/g, '\\u2029');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readJsonFile(path) {
|
|
55
|
+
if (!existsSync(path)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function reactVersionFromPeer(peerRange) {
|
|
66
|
+
if (typeof peerRange !== 'string' || !peerRange) {
|
|
67
|
+
return FALLBACK_REACT_VERSION;
|
|
68
|
+
}
|
|
69
|
+
const exact = peerRange.match(/\d+\.\d+\.\d+/);
|
|
70
|
+
if (exact && !/[<>=~^*x]/i.test(peerRange.replace(exact[0], ''))) {
|
|
71
|
+
return exact[0];
|
|
72
|
+
}
|
|
73
|
+
if (peerRange.includes('19') || peerRange.includes('18')) {
|
|
74
|
+
return FALLBACK_REACT_VERSION;
|
|
75
|
+
}
|
|
76
|
+
return FALLBACK_REACT_VERSION;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveHarnessReactVersion(projectDir) {
|
|
80
|
+
const candidates = [
|
|
81
|
+
join(projectDir, 'node_modules', '@notis', 'sdk', 'package.json'),
|
|
82
|
+
join(REPO_ROOT, 'packages', 'sdk', 'package.json'),
|
|
83
|
+
join(CLI_ROOT, 'template', 'packages', 'sdk', 'package.json'),
|
|
84
|
+
];
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
const pkg = readJsonFile(candidate);
|
|
87
|
+
const peer = pkg?.peerDependencies?.react;
|
|
88
|
+
if (peer) {
|
|
89
|
+
return reactVersionFromPeer(peer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return FALLBACK_REACT_VERSION;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function titleFromSlug(slug) {
|
|
96
|
+
return String(slug || '')
|
|
97
|
+
.replace(/[-_]+/g, ' ')
|
|
98
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeDatabaseDescriptors(databases) {
|
|
102
|
+
return (Array.isArray(databases) ? databases : [])
|
|
103
|
+
.map((entry) => {
|
|
104
|
+
if (typeof entry === 'string') {
|
|
105
|
+
return {
|
|
106
|
+
slug: entry,
|
|
107
|
+
title: titleFromSlug(entry),
|
|
108
|
+
description: null,
|
|
109
|
+
icon: null,
|
|
110
|
+
properties: [],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
|
|
114
|
+
return {
|
|
115
|
+
slug: entry.slug,
|
|
116
|
+
title: entry.title || titleFromSlug(entry.slug),
|
|
117
|
+
description: entry.description || null,
|
|
118
|
+
icon: entry.icon || null,
|
|
119
|
+
properties: Array.isArray(entry.properties) ? entry.properties : [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
})
|
|
124
|
+
.filter(Boolean);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeToolDescriptors(tools) {
|
|
128
|
+
return (Array.isArray(tools) ? tools : [])
|
|
129
|
+
.map((entry) => {
|
|
130
|
+
if (typeof entry === 'string') {
|
|
131
|
+
return { name: entry };
|
|
132
|
+
}
|
|
133
|
+
if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
|
|
134
|
+
return entry;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
})
|
|
138
|
+
.filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function defaultRouteForManifest(manifest) {
|
|
142
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
143
|
+
return routes.find((route) => route?.default) || routes[0] || null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findHarnessRoute(manifest, routeSlug) {
|
|
147
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
148
|
+
if (!routeSlug) {
|
|
149
|
+
return defaultRouteForManifest(manifest);
|
|
150
|
+
}
|
|
151
|
+
return routes.find((route) => route?.slug === routeSlug) || null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario = null }) {
|
|
155
|
+
const databases = normalizeDatabaseDescriptors(
|
|
156
|
+
Array.isArray(appConfig?.databases) && appConfig.databases.length
|
|
157
|
+
? appConfig.databases
|
|
158
|
+
: manifest.databases,
|
|
159
|
+
);
|
|
160
|
+
const tools = normalizeToolDescriptors(
|
|
161
|
+
Array.isArray(appConfig?.tools) && appConfig.tools.length
|
|
162
|
+
? appConfig.tools
|
|
163
|
+
: manifest.tools,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
app: {
|
|
168
|
+
id: state.appId || 'harness-app',
|
|
169
|
+
slug: state.slug,
|
|
170
|
+
name: manifest.app?.name || appConfig?.name || state.slug,
|
|
171
|
+
icon: manifest.app?.icon || appConfig?.icon || null,
|
|
172
|
+
description: manifest.app?.description || appConfig?.description || null,
|
|
173
|
+
},
|
|
174
|
+
route: {
|
|
175
|
+
slug: route.slug,
|
|
176
|
+
path: route.path || '/',
|
|
177
|
+
name: route.name || titleFromSlug(route.slug),
|
|
178
|
+
icon: route.icon || null,
|
|
179
|
+
parentSlug: route.parentSlug || null,
|
|
180
|
+
default: Boolean(route.default),
|
|
181
|
+
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
182
|
+
collection: route.collection || null,
|
|
183
|
+
},
|
|
184
|
+
databases,
|
|
185
|
+
context: { collectionItem: null, resourceId: null, screenshotScenario: scenario },
|
|
186
|
+
tools,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function plainObject(value) {
|
|
191
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve the fixture payload injected into one harness page load.
|
|
196
|
+
*
|
|
197
|
+
* A scenario may override individual `tools` / `requests` keys on top of the
|
|
198
|
+
* file-level defaults, which is how one route renders both its populated and
|
|
199
|
+
* its empty state. Each capture is its own page load, so a shallow per-key
|
|
200
|
+
* merge is all the isolation a scenario needs.
|
|
201
|
+
*/
|
|
202
|
+
function harnessFixtures(projectDir, scenario) {
|
|
203
|
+
const fixtureConfig = readJsonFile(join(projectDir, 'metadata', 'screenshot-fixtures.json')) || {};
|
|
204
|
+
const scenarios = plainObject(fixtureConfig.scenarios);
|
|
205
|
+
const selected = scenario ? plainObject(scenarios[scenario]) : null;
|
|
206
|
+
return {
|
|
207
|
+
tools: { ...plainObject(fixtureConfig.tools), ...plainObject(selected?.tools) },
|
|
208
|
+
requests: { ...plainObject(fixtureConfig.requests), ...plainObject(selected?.requests) },
|
|
209
|
+
scenario: selected && Object.keys(selected).length > 0 ? selected : null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions, scenario = null }) {
|
|
214
|
+
const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
|
|
215
|
+
const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route, scenario });
|
|
216
|
+
const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
|
|
217
|
+
const replacements = {
|
|
218
|
+
'{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
|
|
219
|
+
'{{ROUTE_EXPORT}}': scriptJson(routeExport),
|
|
220
|
+
'{{RUNTIME_DESCRIPTOR}}': scriptJson(descriptor),
|
|
221
|
+
'{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
|
|
222
|
+
'{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
|
|
223
|
+
'{{JWT}}': scriptJson(harnessOptions.jwt || null),
|
|
224
|
+
'{{FIXTURES}}': scriptJson(harnessFixtures(state.projectDir, scenario)),
|
|
225
|
+
};
|
|
226
|
+
let html = template;
|
|
227
|
+
for (const [token, value] of Object.entries(replacements)) {
|
|
228
|
+
html = html.replaceAll(token, value);
|
|
229
|
+
}
|
|
230
|
+
return html;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
/** Serve only explicitly supplied built projects, without discovery or filesystem watchers. */
|
|
235
|
+
export async function startAppTestServer({ apps, port, harness = {} }) {
|
|
236
|
+
if (!Array.isArray(apps) || !apps.length) throw new Error('At least one built app is required.');
|
|
237
|
+
const states = new Map();
|
|
238
|
+
for (const app of apps) {
|
|
239
|
+
states.set(app.slug, {
|
|
240
|
+
...app,
|
|
241
|
+
manifest: readManifest(app.projectDir),
|
|
242
|
+
appConfig: await loadAppConfig(app.projectDir),
|
|
243
|
+
bundleDir: getBundleDir(app.projectDir),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const server = createServer((req, res) => {
|
|
247
|
+
const headers = corsHeaders(req.headers.origin || '');
|
|
248
|
+
const respond = (status, content, contentType = 'text/plain; charset=utf-8') => {
|
|
249
|
+
res.writeHead(status, { ...headers, 'Content-Type': contentType });
|
|
250
|
+
res.end(req.method === 'HEAD' ? undefined : content);
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
if (req.headers.origin && !isAllowedOrigin(req.headers.origin)) return respond(403, 'origin not allowed');
|
|
254
|
+
if (req.method === 'OPTIONS') return respond(204, '');
|
|
255
|
+
if (!['GET', 'HEAD'].includes(req.method)) return respond(405, 'method not allowed');
|
|
256
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
257
|
+
if (url.pathname === '/healthz') return respond(200, JSON.stringify({ ok: true }), 'application/json');
|
|
258
|
+
const match = url.pathname.match(/^\/a\/([^/]+)\/(.*)$/);
|
|
259
|
+
const state = match && states.get(decodeURIComponent(match[1]));
|
|
260
|
+
if (!state) return respond(404, 'not found');
|
|
261
|
+
if (match[2] === 'harness') {
|
|
262
|
+
const route = findHarnessRoute(state.manifest, url.searchParams.get('route') || '');
|
|
263
|
+
if (!route) return respond(404, 'unknown route');
|
|
264
|
+
return respond(200, renderHarnessHtml({ state, manifest: state.manifest, appConfig: state.appConfig, route, harnessOptions: harness, scenario: url.searchParams.get('scenario') }), 'text/html; charset=utf-8');
|
|
265
|
+
}
|
|
266
|
+
if (match[2].startsWith('bundle/')) {
|
|
267
|
+
const relativePath = decodeURIComponent(match[2].slice('bundle/'.length));
|
|
268
|
+
const file = safeJoin(state.bundleDir, relativePath);
|
|
269
|
+
if (!file || !existsSync(file) || !statSync(file).isFile()) return respond(404, 'not found');
|
|
270
|
+
return respond(200, readFileSync(file), CONTENT_TYPES[extFor(file)] || 'application/octet-stream');
|
|
271
|
+
}
|
|
272
|
+
return respond(404, 'not found');
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return respond(500, error instanceof Error ? error.message : String(error));
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
await new Promise((accept, reject) => {
|
|
278
|
+
server.once('error', reject);
|
|
279
|
+
server.listen(port, '127.0.0.1', () => { server.off('error', reject); accept(); });
|
|
280
|
+
});
|
|
281
|
+
let closing;
|
|
282
|
+
return {
|
|
283
|
+
port: server.address().port,
|
|
284
|
+
close() {
|
|
285
|
+
closing ||= new Promise((accept) => {
|
|
286
|
+
server.close(accept);
|
|
287
|
+
server.closeAllConnections?.();
|
|
288
|
+
});
|
|
289
|
+
return closing;
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
package/template/app/page.tsx
CHANGED
|
@@ -1,60 +1,62 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { getDocumentPreview, useDocuments, useNotis } from '@notis/sdk';
|
|
3
|
+
import { getDocumentPreview, useDocuments, useNotis, Skeleton, ViewSkeleton } from '@notis/sdk';
|
|
4
4
|
import { Badge } from '@/components/ui/badge';
|
|
5
|
-
import {
|
|
5
|
+
import { PageHeading } from '@/components/page-heading';
|
|
6
6
|
|
|
7
|
+
// Reference page for the flat Notis design bar: a plain page header, bare
|
|
8
|
+
// figures, and a hairline-free list. No bordered boxes, no dividers, no
|
|
9
|
+
// palette colors, no loading text. `notis apps build` enforces these rules.
|
|
7
10
|
export default function HomePage() {
|
|
8
11
|
const { app, ready } = useNotis();
|
|
9
12
|
// Documents come back normalized: plain property values, camelCase fields,
|
|
10
13
|
// and typed content (contentMarkdown / contentBlocknote / plainText).
|
|
11
|
-
const { documents, loading } = useDocuments('items', { pageSize: 25 });
|
|
14
|
+
const { documents, loading, hasData, error, refetch } = useDocuments('items', { pageSize: 25 });
|
|
12
15
|
|
|
13
16
|
return (
|
|
14
|
-
<main className="notis-app-shell space-y-
|
|
15
|
-
<
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
<main className="notis-app-shell space-y-8">
|
|
18
|
+
<PageHeading
|
|
19
|
+
title={ready ? app?.name ?? 'Items' : <Skeleton style={{ width: 160, height: 28 }} />}
|
|
20
|
+
description={ready ? app?.description ?? undefined : <Skeleton style={{ width: 240 }} />}
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
<section className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
|
|
24
|
+
<div className="min-w-0">
|
|
25
|
+
<p className="text-xs font-medium text-muted-foreground">Items</p>
|
|
26
|
+
<p className="mt-1 text-2xl font-semibold tabular-nums">
|
|
27
|
+
{hasData ? documents.length : <Skeleton style={{ width: 48, height: 28 }} />}
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
</section>
|
|
26
31
|
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<Badge variant="outline">{doc.properties.status}</Badge>
|
|
47
|
-
) : null}
|
|
48
|
-
</div>
|
|
49
|
-
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
|
50
|
-
{getDocumentPreview(doc)}
|
|
51
|
-
</p>
|
|
32
|
+
<section className="space-y-3">
|
|
33
|
+
<h2 className="text-base font-semibold">Recent items</h2>
|
|
34
|
+
{error ? (
|
|
35
|
+
<p role="alert" className="rounded-xl bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
36
|
+
{error.message}{' '}
|
|
37
|
+
<button type="button" className="font-medium underline" onClick={refetch}>Retry</button>
|
|
38
|
+
</p>
|
|
39
|
+
) : null}
|
|
40
|
+
{loading ? (
|
|
41
|
+
<ViewSkeleton variant="table" rows={4} />
|
|
42
|
+
) : !hasData ? null : documents.length === 0 ? (
|
|
43
|
+
<p className="px-4 py-10 text-center text-sm text-muted-foreground">No items yet. Create your first item.</p>
|
|
44
|
+
) : (
|
|
45
|
+
<div className="space-y-2 lg:space-y-0">
|
|
46
|
+
{documents.map((doc) => (
|
|
47
|
+
<div key={doc.id} className="list-row flex items-center justify-between gap-3">
|
|
48
|
+
<div className="min-w-0">
|
|
49
|
+
<p className="truncate text-sm font-medium">{doc.title || 'Untitled'}</p>
|
|
50
|
+
<p className="mt-0.5 truncate text-xs text-muted-foreground">{getDocumentPreview(doc)}</p>
|
|
52
51
|
</div>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
{typeof doc.properties.status === 'string' ? (
|
|
53
|
+
<Badge variant="secondary" className="shrink-0">{doc.properties.status}</Badge>
|
|
54
|
+
) : null}
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</section>
|
|
58
60
|
</main>
|
|
59
61
|
);
|
|
60
62
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
// Mirrors the portal's PortalPageHeader: plain title, one-line description,
|
|
4
|
+
// actions on the right. No eyebrow, no rule underneath.
|
|
5
|
+
export function PageHeading({
|
|
6
|
+
title,
|
|
7
|
+
description,
|
|
8
|
+
actions,
|
|
9
|
+
}: {
|
|
10
|
+
title: ReactNode;
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
actions?: ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
16
|
+
<div className="min-w-0 flex-1">
|
|
17
|
+
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
|
|
18
|
+
{description ? <p className="mt-1.5 text-sm text-muted-foreground">{description}</p> : null}
|
|
19
|
+
</div>
|
|
20
|
+
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">{actions}</div> : null}
|
|
21
|
+
</header>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -4,13 +4,16 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|
|
4
4
|
import { cn } from '@/lib/utils';
|
|
5
5
|
|
|
6
6
|
const badgeVariants = cva(
|
|
7
|
-
'inline-flex items-center rounded-full
|
|
7
|
+
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
11
|
-
default: '
|
|
12
|
-
secondary: '
|
|
13
|
-
|
|
11
|
+
default: 'bg-primary/10 text-primary',
|
|
12
|
+
secondary: 'bg-foreground/[0.07] text-foreground',
|
|
13
|
+
destructive: 'bg-destructive/10 text-destructive',
|
|
14
|
+
// `outline` is kept for API compatibility and renders as plain text: the
|
|
15
|
+
// design bar has no outlined badges.
|
|
16
|
+
outline: 'bg-transparent text-foreground',
|
|
14
17
|
},
|
|
15
18
|
},
|
|
16
19
|
defaultVariants: {
|
|
@@ -2,20 +2,33 @@ import * as React from 'react';
|
|
|
2
2
|
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
|
|
5
|
+
// Surfaces are flat: a tinted panel on the page, and a plain background panel
|
|
6
|
+
// when nested inside another Card. No borders, no dividers, no shadows.
|
|
7
|
+
const CardDepthContext = React.createContext(0);
|
|
8
|
+
|
|
5
9
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
6
|
-
({ className, ...props }, ref) =>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
({ className, ...props }, ref) => {
|
|
11
|
+
const depth = React.useContext(CardDepthContext);
|
|
12
|
+
return (
|
|
13
|
+
<CardDepthContext.Provider value={depth + 1}>
|
|
14
|
+
<div
|
|
15
|
+
ref={ref}
|
|
16
|
+
className={cn(
|
|
17
|
+
'min-w-0 rounded-2xl text-card-foreground',
|
|
18
|
+
depth === 0 ? 'bg-muted' : 'bg-background',
|
|
19
|
+
className,
|
|
20
|
+
)}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
</CardDepthContext.Provider>
|
|
24
|
+
);
|
|
25
|
+
},
|
|
13
26
|
);
|
|
14
27
|
Card.displayName = 'Card';
|
|
15
28
|
|
|
16
29
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
17
30
|
({ className, ...props }, ref) => (
|
|
18
|
-
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-
|
|
31
|
+
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} />
|
|
19
32
|
),
|
|
20
33
|
);
|
|
21
34
|
CardHeader.displayName = 'CardHeader';
|
|
@@ -24,7 +37,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
|
|
|
24
37
|
({ className, ...props }, ref) => (
|
|
25
38
|
<h3
|
|
26
39
|
ref={ref}
|
|
27
|
-
className={cn('text-
|
|
40
|
+
className={cn('text-base font-semibold leading-none tracking-tight', className)}
|
|
28
41
|
{...props}
|
|
29
42
|
/>
|
|
30
43
|
),
|
|
@@ -41,14 +54,14 @@ CardDescription.displayName = 'CardDescription';
|
|
|
41
54
|
|
|
42
55
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
43
56
|
({ className, ...props }, ref) => (
|
|
44
|
-
<div ref={ref} className={cn('p-
|
|
57
|
+
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
|
45
58
|
),
|
|
46
59
|
);
|
|
47
60
|
CardContent.displayName = 'CardContent';
|
|
48
61
|
|
|
49
62
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
50
63
|
({ className, ...props }, ref) => (
|
|
51
|
-
<div ref={ref} className={cn('flex items-center p-
|
|
64
|
+
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
|
|
52
65
|
),
|
|
53
66
|
);
|
|
54
67
|
CardFooter.displayName = 'CardFooter';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { CaretDown } from '@phosphor-icons/react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
// Borderless native select: a tinted pill with a caret. Keeps the platform
|
|
7
|
+
// dropdown (no portal needed inside the app surface) and the portal focus ring.
|
|
8
|
+
const NativeSelect = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
|
|
9
|
+
({ className, children, ...props }, ref) => (
|
|
10
|
+
<span className={cn('relative inline-flex', className)}>
|
|
11
|
+
<select
|
|
12
|
+
ref={ref}
|
|
13
|
+
className="h-9 w-full appearance-none rounded-md bg-muted pl-3 pr-8 text-sm text-foreground outline-none transition-colors hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
14
|
+
{...props}
|
|
15
|
+
>
|
|
16
|
+
{children}
|
|
17
|
+
</select>
|
|
18
|
+
<CaretDown className="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
19
|
+
</span>
|
|
20
|
+
),
|
|
21
|
+
);
|
|
22
|
+
NativeSelect.displayName = 'NativeSelect';
|
|
23
|
+
|
|
24
|
+
export { NativeSelect };
|
package/template/notis.config.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { defineNotisApp } from '@notis/sdk/config';
|
|
|
2
2
|
|
|
3
3
|
export default defineNotisApp({
|
|
4
4
|
name: 'my-notis-app',
|
|
5
|
-
devSlug: 'my-notis-app',
|
|
6
5
|
title: 'My Notis App',
|
|
7
6
|
description: 'A focused Notis app with one route and one database-backed workflow.',
|
|
8
7
|
icon: 'phosphor:squares-four',
|
package/template/package.json
CHANGED