@montytools/cli 0.2.9 → 0.4.0
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/bin/monty.mjs +1405 -104
- package/lib/compile.mjs +67 -0
- package/package.json +8 -2
- package/skills/monty-build/SKILL.md +37 -17
- package/template/.claude/settings.json +5 -0
- package/template/AGENTS.md +68 -10
- package/template/index.html +1 -1
- package/template/monty.config.ts +14 -13
- package/template/package.json +1 -1
- package/template/src/routes/index.tsx +20 -245
package/lib/compile.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The ONE config-compile pipeline: esbuild-bundle a temp entry that runs the
|
|
2
|
+
// app's OWN compileApp/zod/sdk instances on monty.config.ts, execute it in a
|
|
3
|
+
// subprocess, parse the emitted metadata. Used by `monty dev`/`monty deploy`
|
|
4
|
+
// (bin/monty.mjs) and the demo harness (scripts/demo.mjs) — one pipeline, so
|
|
5
|
+
// what a demo installs is byte-for-byte what a deploy would send.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
export class CompileError extends Error {
|
|
11
|
+
constructor(code, fix) {
|
|
12
|
+
super(`[${code}] ${fix}`);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.fix = fix;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Compile appDir/monty.config.ts → { slug, name, icon?, schemaJson, exposure? }.
|
|
19
|
+
// Throws CompileError:
|
|
20
|
+
// - CONFIG_BUNDLE_FAILED — esbuild could not bundle (usually unlinked deps)
|
|
21
|
+
// - CONFIG_COMPILE_FAILED — the config threw while loading
|
|
22
|
+
export async function compileAppConfig(appDir) {
|
|
23
|
+
appDir = resolve(appDir); // esbuild requires an absolute absWorkingDir
|
|
24
|
+
// esbuild is a dependency of THIS package (@montytools/cli), so resolution
|
|
25
|
+
// from here works for any caller — no per-script resolution dance.
|
|
26
|
+
const { build } = await import("esbuild");
|
|
27
|
+
const tmpDir = join(appDir, ".monty");
|
|
28
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
29
|
+
const entry = join(tmpDir, "compile-entry.mjs");
|
|
30
|
+
const out = join(tmpDir, "compile-out.mjs");
|
|
31
|
+
writeFileSync(entry, [
|
|
32
|
+
`import { app } from "../monty.config";`,
|
|
33
|
+
`import { compileApp } from "@montytools/sdk/compile";`,
|
|
34
|
+
`process.stdout.write(JSON.stringify(compileApp(app)));`,
|
|
35
|
+
].join("\n"));
|
|
36
|
+
try {
|
|
37
|
+
await build({
|
|
38
|
+
entryPoints: [entry],
|
|
39
|
+
outfile: out,
|
|
40
|
+
bundle: true,
|
|
41
|
+
platform: "node",
|
|
42
|
+
format: "esm",
|
|
43
|
+
target: "node22",
|
|
44
|
+
absWorkingDir: appDir,
|
|
45
|
+
logLevel: "silent",
|
|
46
|
+
});
|
|
47
|
+
const result = spawnSync(process.execPath, [out], { encoding: "utf8" });
|
|
48
|
+
if (result.status !== 0) {
|
|
49
|
+
throw new CompileError(
|
|
50
|
+
"CONFIG_COMPILE_FAILED",
|
|
51
|
+
`monty.config.ts threw while loading:\n${result.stderr}\nFix the config (it must only call defineApp with zod tables).`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return JSON.parse(result.stdout);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (e?.errors) {
|
|
57
|
+
throw new CompileError(
|
|
58
|
+
"CONFIG_BUNDLE_FAILED",
|
|
59
|
+
`esbuild could not bundle monty.config.ts: ${e.errors[0]?.text ?? e.message}\nRun \`pnpm install\` so the app's deps link, then retry.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
throw e;
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(entry, { force: true });
|
|
65
|
+
rmSync(out, { force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@montytools/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/TomasMonty/monty-v2.git",
|
|
7
|
+
"directory": "packages/cli"
|
|
8
|
+
},
|
|
4
9
|
"type": "module",
|
|
5
10
|
"bin": {
|
|
6
11
|
"monty": "./bin/monty.mjs"
|
|
7
12
|
},
|
|
8
13
|
"files": [
|
|
9
14
|
"bin",
|
|
15
|
+
"lib",
|
|
10
16
|
"template",
|
|
11
17
|
"skills"
|
|
12
18
|
],
|
|
@@ -15,7 +21,7 @@
|
|
|
15
21
|
},
|
|
16
22
|
"scripts": {
|
|
17
23
|
"prepack": "node scripts/bundle-template.mjs",
|
|
18
|
-
"typecheck": "node --check bin/monty.mjs",
|
|
24
|
+
"typecheck": "node --check bin/monty.mjs && node --check lib/compile.mjs",
|
|
19
25
|
"postinstall": "node bin/postinstall.mjs"
|
|
20
26
|
},
|
|
21
27
|
"dependencies": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: monty-build
|
|
3
|
-
description: Build, run, and deploy Monty workspace apps. Use whenever the task involves a Monty app, monty.config.ts, the monty CLI (create/dev/deploy/add), the @montytools/sdk, or a prompt mentioning usemonty.dev. Covers folder discipline, the build loop, data/auth rules, and error handling.
|
|
3
|
+
description: Build, run, and deploy Monty workspace apps. Use whenever the task involves a Monty app, monty.config.ts, the monty CLI (create/dev/logs/deploy/add), the @montytools/sdk, or a prompt mentioning usemonty.dev. Covers folder discipline, the build loop, data/auth rules, and error handling.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Building Monty apps
|
|
@@ -11,25 +11,43 @@ platform's job. The complete contract lives in the app's own `AGENTS.md`
|
|
|
11
11
|
(nearest-file-wins — read it before writing code). This skill is the map, not
|
|
12
12
|
the territory.
|
|
13
13
|
|
|
14
|
+
Every app has two states: **Live** (published — what members and visitors
|
|
15
|
+
see) and **Studio** (development — a running dev shell on `#dev`-sandboxed
|
|
16
|
+
data, often already started for you by the Monty desktop). Those are the only
|
|
17
|
+
names for them; "dev"/"prod" mean something else on this platform.
|
|
18
|
+
|
|
14
19
|
## Rules
|
|
15
20
|
|
|
16
|
-
1. **Folders are managed.** Apps live in
|
|
17
|
-
|
|
18
|
-
`monty
|
|
19
|
-
|
|
21
|
+
1. **Folders are managed.** Apps live in `~/.monty/apps/<id>` (a server-issued
|
|
22
|
+
id, minted when `monty create` registers the app — so create needs
|
|
23
|
+
`monty login` first). `monty current` tells you where you are;
|
|
24
|
+
`cd "$(monty select <slug>)"` jumps to an app; `monty apps` lists local
|
|
25
|
+
ones. Never mkdir app folders by hand, and never edit the `id:` line in
|
|
26
|
+
`monty.config.ts`.
|
|
27
|
+
2. **The loop:** `monty create <slug> --name "Name" --icon <tabler-icon>` →
|
|
20
28
|
(if the prompt includes a `build id`, pass it: `--build <id>` — the
|
|
21
29
|
workspace's New app screen tracks your progress live) →
|
|
22
30
|
`monty install` → edit `monty.config.ts` (zod tables) + `src/routes/` →
|
|
23
|
-
verify
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
verify in Studio: run `monty dev` once — if the dev shell is already
|
|
32
|
+
running (the Monty desktop usually runs it for you) it prints the status,
|
|
33
|
+
Studio URL, and recent log lines, then **exits immediately**; if nothing
|
|
34
|
+
is running it starts the shell (start it in the background and move on).
|
|
35
|
+
Then iterate: edit code → vite hot-reloads → `monty logs -n 50` shows
|
|
36
|
+
whether it compiled and any browser errors. **You are done when
|
|
37
|
+
`monty dev` reports `state: online` (or prints the Studio URL on a fresh
|
|
38
|
+
start) and the app works.** Re-running `monty dev` is always safe — it
|
|
39
|
+
attaches, prints status, and exits. Never try to run a second dev
|
|
40
|
+
*server* for the same app (attach handles this for you) and never kill a
|
|
41
|
+
dev shell you didn't start; `monty dev --takeover` is the only sanctioned
|
|
42
|
+
restart when a session is wedged. Taking the app Live is the OWNER'S
|
|
43
|
+
click (Publish in the workspace menu bar); **never run `monty deploy`
|
|
44
|
+
yourself** unless the user explicitly asks for a direct Live deploy.
|
|
29
45
|
3. **Everything through the CLI.** `monty install`, `monty build`,
|
|
30
46
|
`monty typecheck`, `monty dev`, `monty deploy` — never run vite, tsc,
|
|
31
47
|
pnpm, or npm scripts directly. `monty dev` auto-picks a free port and
|
|
32
|
-
prints it; `monty typecheck` builds first when needed.
|
|
48
|
+
prints it; `monty typecheck` builds first when needed. `monty logs`
|
|
49
|
+
(add `-f` to follow) is how you read the dev shell's output — vite build
|
|
50
|
+
errors, browser errors, and publish results all land there.
|
|
33
51
|
4. **One import surface:** `@montytools/sdk` (`defineApp`, zod) and
|
|
34
52
|
`@montytools/sdk/react` (hooks: `useList`, `useInsert`, …). Never import
|
|
35
53
|
Clerk or Convex directly; never fetch external APIs from app code — the
|
|
@@ -41,18 +59,20 @@ the territory.
|
|
|
41
59
|
7. **Errors are instructions.** Every failure prints
|
|
42
60
|
`[MontyError CODE] Fix: …` — do exactly what the Fix says; don't guess.
|
|
43
61
|
Typecheck failures block deploy by design.
|
|
44
|
-
8. **Verify before
|
|
45
|
-
team records are never touched, so exercise the app for
|
|
62
|
+
8. **Verify before publish.** The dev shell writes to the `#dev` Studio
|
|
63
|
+
sandbox — Live team records are never touched, so exercise the app for
|
|
64
|
+
real.
|
|
46
65
|
|
|
47
66
|
## CLI reference
|
|
48
67
|
|
|
49
68
|
| command | purpose |
|
|
50
69
|
|---|---|
|
|
51
70
|
| `monty login` | browser sign-in (loopback authorize), once per machine |
|
|
52
|
-
| `monty create <slug>` |
|
|
71
|
+
| `monty create <slug>` | register the app in the workspace + stamp it into `~/.monty/apps/<id>` (needs login) |
|
|
53
72
|
| `monty current` / `select` / `apps` | where am I / jump to app / list local |
|
|
54
73
|
| `monty install` / `build` / `typecheck` | full lifecycle via the CLI — no raw pnpm/vite/tsc |
|
|
55
|
-
| `monty dev` | run
|
|
74
|
+
| `monty dev` | run the app in Studio, or attach to an already-running session (auto-port, sandboxed data, auto-auth) |
|
|
75
|
+
| `monty logs [-n N] [-f]` | read/follow the dev shell log — the debugging window after every edit |
|
|
56
76
|
| `monty add <name…>` | install curated shadcn components |
|
|
57
|
-
| `monty deploy` | build + typecheck + upload
|
|
77
|
+
| `monty deploy` | build + typecheck + upload straight to Live (owner escape hatch) |
|
|
58
78
|
| `monty skills` | (re)install this skill for your agent |
|
package/template/AGENTS.md
CHANGED
|
@@ -10,8 +10,8 @@ Folders are managed for you: this app lives in `~/Monty/<slug>`. `monty current`
|
|
|
10
10
|
|
|
11
11
|
| File | What it is |
|
|
12
12
|
|---|---|
|
|
13
|
-
| `monty.config.ts` | Your data schema — plain zod. The ONLY place data shapes are defined. Also `name` + `icon` (any [
|
|
14
|
-
| `src/routes/` | Your UI — TanStack Router file routes (`index.tsx` = `/`). |
|
|
13
|
+
| `monty.config.ts` | Your data schema — plain zod. The ONLY place data shapes are defined. Also `name` + `icon` (any [Tabler](https://tabler.io/icons) icon name, e.g. `"receipt"`, `"users"`) — Monty renders your app's logo tile from it. |
|
|
14
|
+
| `src/routes/` | Your UI — TanStack Router file routes (`index.tsx` = `/`). The starter `index.tsx` is a blank-canvas placeholder — replace it with the real app. |
|
|
15
15
|
| `src/main.tsx` | Wiring. Do not edit. |
|
|
16
16
|
|
|
17
17
|
## Rules (violations break the app)
|
|
@@ -110,6 +110,63 @@ Every platform error is one line shaped like:
|
|
|
110
110
|
| `UNAUTHENTICATED` / `NO_ACTIVE_WORKSPACE` | App isn't running through the Monty host/dev shell. |
|
|
111
111
|
| `MISSING_ENV` / `NO_PROVIDER` | `.env.local` or the `<MontyProvider>` in `main.tsx` was removed. |
|
|
112
112
|
|
|
113
|
+
## Server code (optional): functions, public endpoints, schedules
|
|
114
|
+
|
|
115
|
+
When logic must not run in the browser (private tables, third-party APIs with
|
|
116
|
+
secret keys, webhooks, clocks), create `server/index.ts` with named async
|
|
117
|
+
exports. Web-standard APIs only (`fetch`, `crypto`, `URL`, …) — no `node:`
|
|
118
|
+
imports; it runs on Cloudflare Workers when Live and inside `monty dev` in
|
|
119
|
+
Studio. Every export gets `ctx`: `ctx.records` (full CRUD on all your
|
|
120
|
+
tables, including ones the UI never exposes), `ctx.secrets` (see below),
|
|
121
|
+
`ctx.viewer` (who called: member session/visitor/schedule/none), and
|
|
122
|
+
`ctx.track()` (emit an event).
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// server/index.ts
|
|
126
|
+
import type { MontyFnContext, MontyPublicRequest } from "@montytools/sdk/server";
|
|
127
|
+
|
|
128
|
+
// 1) App-called: invoke from the UI with useServerFn.
|
|
129
|
+
export async function score(args: { sessionId: string }, ctx: MontyFnContext) {
|
|
130
|
+
const weights = await ctx.records.list("scoring"); // hidden table — fine here
|
|
131
|
+
return { verdict: weights.length > 0 ? "ok" : "empty" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2) PUBLIC endpoint — declared in monty.config.ts `publicFns: ["stripe"]`,
|
|
135
|
+
// then ANYONE on the internet can call GET/POST /__monty/public/stripe.
|
|
136
|
+
// Verify a signature from ctx.secrets against the RAW req.body before
|
|
137
|
+
// trusting anything; return { status, body?, contentType? } to control the
|
|
138
|
+
// response (or return data for 200 JSON, or nothing for {"ok":true}).
|
|
139
|
+
export async function stripe(req: MontyPublicRequest, ctx: MontyFnContext) {
|
|
140
|
+
if (!verify(req.headers["stripe-signature"], req.body, ctx.secrets.STRIPE_WEBHOOK_SECRET)) {
|
|
141
|
+
return { status: 401, body: "bad signature" };
|
|
142
|
+
}
|
|
143
|
+
await ctx.records.insert("payments", JSON.parse(req.body));
|
|
144
|
+
return { status: 200, body: "ok" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3) SCHEDULED — declared in monty.config.ts `schedule: { digest: "0 9 * * *" }`
|
|
148
|
+
// (5-field cron, UTC; max 3). Runs as digest({}, ctx) with
|
|
149
|
+
// ctx.viewer = { lane: "schedule", cron }. Make it idempotent — re-runs happen.
|
|
150
|
+
export async function digest(_args: Record<string, unknown>, ctx: MontyFnContext) { /* … */ }
|
|
151
|
+
|
|
152
|
+
// 4) onEvent — reserved name: the platform calls it after every accepted
|
|
153
|
+
// track() event. Forward to Slack/Meta/PostHog/your CRM here, with your keys.
|
|
154
|
+
export async function onEvent(event: { name: string }, ctx: MontyFnContext) { /* … */ }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
From the UI: `const score = useServerFn<Result>(app, "score"); await score({ sessionId })`.
|
|
158
|
+
Public functions are NOT callable through `useServerFn` — only at their
|
|
159
|
+
`/__monty/public/<name>` URL (and vice versa: undeclared functions are never
|
|
160
|
+
public).
|
|
161
|
+
|
|
162
|
+
**Secrets:** `monty secret set STRIPE_KEY` stores a key for Live (write-only,
|
|
163
|
+
never in code or config). In Studio, put the same names in the gitignored
|
|
164
|
+
`.monty/secrets.json`. Both arrive as `ctx.secrets.STRIPE_KEY`.
|
|
165
|
+
|
|
166
|
+
**Studio behavior:** `monty dev` runs your schedules for real (a `cron:` line
|
|
167
|
+
prints per firing, UTC) and serves public functions at
|
|
168
|
+
`http://localhost:<port>/__monty/public/<name>` — curl them to test.
|
|
169
|
+
|
|
113
170
|
## Dev loop
|
|
114
171
|
|
|
115
172
|
Everything goes through the `monty` CLI — never run vite, tsc, pnpm, or npm
|
|
@@ -123,11 +180,12 @@ monty dev # Vite + HMR, auto-picks a free port and prints it
|
|
|
123
180
|
Headless? Verify with `monty build` then `monty typecheck` (typecheck builds
|
|
124
181
|
first when needed — the build generates `src/routeTree.gen.ts`).
|
|
125
182
|
|
|
126
|
-
**
|
|
127
|
-
the
|
|
128
|
-
data).
|
|
183
|
+
**Studio vs Live:** every Monty app has two states. While `monty dev` runs,
|
|
184
|
+
the app is in **Studio** — visible in the workspace to admins only
|
|
185
|
+
(tunneled, `#dev` sandboxed data). **Live** is the published state the
|
|
186
|
+
whole team sees. Going Live is the owner's **Publish** click in the
|
|
129
187
|
workspace menu bar — it signals your running `monty dev`, which builds,
|
|
130
|
-
typechecks, and uploads. You are done when the app works in
|
|
188
|
+
typechecks, and uploads. You are done when the app works in Studio;
|
|
131
189
|
leave `monty dev` running and let the owner publish. Only run
|
|
132
190
|
`monty deploy` directly if the user explicitly asks.
|
|
133
191
|
|
|
@@ -135,12 +193,12 @@ leave `monty dev` running and let the owner publish. Only run
|
|
|
135
193
|
`http://localhost:5173` is ALREADY AUTHENTICATED — no sign-in screen (the dev
|
|
136
194
|
server mints short-lived workspace tokens from the CLI login). Point
|
|
137
195
|
Playwright or any browser automation at it, click through your app against
|
|
138
|
-
|
|
196
|
+
reactive sandboxed data, and read your errors in the `monty dev` terminal
|
|
139
197
|
(`[browser:error] …` lines). Edit → HMR → look → fix: verify your own work.
|
|
140
198
|
|
|
141
|
-
|
|
142
|
-
iterate freely,
|
|
143
|
-
confirms it.
|
|
199
|
+
Studio writes go to a sandboxed `#dev` namespace inside your real workspace —
|
|
200
|
+
iterate freely, Live app data is untouched. The "STUDIO · … · sandbox data"
|
|
201
|
+
badge confirms it.
|
|
144
202
|
|
|
145
203
|
## Modeling tips
|
|
146
204
|
|
package/template/index.html
CHANGED
package/template/monty.config.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
1
|
import { defineApp } from "@montytools/sdk";
|
|
3
2
|
|
|
4
3
|
// This file is the ONLY place data shapes are defined. Edit it, save, and the
|
|
5
|
-
// typed hooks in your components update immediately.
|
|
4
|
+
// typed hooks in your components update immediately. The starter ships with no
|
|
5
|
+
// tables — declare each one as a zod object as the app needs it, e.g.:
|
|
6
|
+
//
|
|
7
|
+
// import { z } from "zod";
|
|
8
|
+
// tables: {
|
|
9
|
+
// tasks: z.object({
|
|
10
|
+
// title: z.string().min(1),
|
|
11
|
+
// done: z.boolean().default(false),
|
|
12
|
+
// }),
|
|
13
|
+
// },
|
|
6
14
|
export const app = defineApp({
|
|
7
|
-
slug: "
|
|
8
|
-
name: "
|
|
9
|
-
icon: "
|
|
10
|
-
tables: {
|
|
11
|
-
expenses: z.object({
|
|
12
|
-
title: z.string().min(1),
|
|
13
|
-
amount: z.number().positive(),
|
|
14
|
-
status: z.enum(["draft", "submitted", "approved"]).default("draft"),
|
|
15
|
-
assigneeId: z.string().optional(), // a workspace member's userId (from useMembers)
|
|
16
|
-
}),
|
|
17
|
-
},
|
|
15
|
+
slug: "new-app",
|
|
16
|
+
name: "New app",
|
|
17
|
+
icon: "layout-grid",
|
|
18
|
+
tables: {},
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
export type App = typeof app;
|
package/template/package.json
CHANGED
|
@@ -1,263 +1,38 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
3
|
-
import {
|
|
2
|
+
import { Sparkles } from "lucide-react";
|
|
4
3
|
|
|
5
|
-
import {
|
|
6
|
-
useInsert,
|
|
7
|
-
useList,
|
|
8
|
-
useMembers,
|
|
9
|
-
useRemove,
|
|
10
|
-
useUpdate,
|
|
11
|
-
} from "@montytools/sdk/react";
|
|
12
|
-
import { Badge } from "@/components/ui/badge";
|
|
13
|
-
import { Button } from "@/components/ui/button";
|
|
14
|
-
import {
|
|
15
|
-
Card,
|
|
16
|
-
CardContent,
|
|
17
|
-
CardHeader,
|
|
18
|
-
CardTitle,
|
|
19
|
-
} from "@/components/ui/card";
|
|
20
4
|
import {
|
|
21
5
|
Empty,
|
|
22
6
|
EmptyDescription,
|
|
23
7
|
EmptyHeader,
|
|
8
|
+
EmptyMedia,
|
|
24
9
|
EmptyTitle,
|
|
25
10
|
} from "@/components/ui/empty";
|
|
26
|
-
import {
|
|
27
|
-
Field,
|
|
28
|
-
FieldError,
|
|
29
|
-
FieldGroup,
|
|
30
|
-
FieldLabel,
|
|
31
|
-
} from "@/components/ui/field";
|
|
32
|
-
import { Input } from "@/components/ui/input";
|
|
33
|
-
import {
|
|
34
|
-
Select,
|
|
35
|
-
SelectContent,
|
|
36
|
-
SelectGroup,
|
|
37
|
-
SelectItem,
|
|
38
|
-
SelectTrigger,
|
|
39
|
-
SelectValue,
|
|
40
|
-
} from "@/components/ui/select";
|
|
41
|
-
import { Spinner } from "@/components/ui/spinner";
|
|
42
|
-
import {
|
|
43
|
-
Table,
|
|
44
|
-
TableBody,
|
|
45
|
-
TableCell,
|
|
46
|
-
TableHead,
|
|
47
|
-
TableHeader,
|
|
48
|
-
TableRow,
|
|
49
|
-
} from "@/components/ui/table";
|
|
50
11
|
|
|
51
12
|
import { app } from "../../monty.config";
|
|
52
13
|
|
|
53
14
|
export const Route = createFileRoute("/")({
|
|
54
|
-
component:
|
|
15
|
+
component: BlankCanvas,
|
|
55
16
|
});
|
|
56
17
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
approved: null,
|
|
62
|
-
};
|
|
63
|
-
const STATUS_VARIANT = {
|
|
64
|
-
draft: "secondary",
|
|
65
|
-
submitted: "default",
|
|
66
|
-
approved: "outline",
|
|
67
|
-
} as const;
|
|
68
|
-
|
|
69
|
-
function ExpenseBoard() {
|
|
70
|
-
const [statusFilter, setStatusFilter] = useState<Status | "all">("all");
|
|
71
|
-
|
|
72
|
-
// Live, typed, reactive — a teammate's write re-renders this list.
|
|
73
|
-
const { data: expenses, status, loadMore } = useList(app, "expenses", {
|
|
74
|
-
filter: statusFilter === "all" ? undefined : { status: statusFilter },
|
|
75
|
-
order: "desc",
|
|
76
|
-
limit: 50,
|
|
77
|
-
});
|
|
78
|
-
const members = useMembers();
|
|
79
|
-
const insert = useInsert(app, "expenses");
|
|
80
|
-
const update = useUpdate(app, "expenses");
|
|
81
|
-
const remove = useRemove(app, "expenses");
|
|
82
|
-
|
|
83
|
-
const [title, setTitle] = useState("");
|
|
84
|
-
const [amount, setAmount] = useState("");
|
|
85
|
-
// "__none" sentinel: a controlled Radix Select must never flip to undefined
|
|
86
|
-
// (it goes uncontrolled and keeps displaying the stale selection).
|
|
87
|
-
const [assigneeId, setAssigneeId] = useState("__none");
|
|
88
|
-
const [formError, setFormError] = useState<string | null>(null);
|
|
89
|
-
|
|
90
|
-
async function addExpense() {
|
|
91
|
-
setFormError(null);
|
|
92
|
-
try {
|
|
93
|
-
await insert({
|
|
94
|
-
title,
|
|
95
|
-
amount: Number(amount),
|
|
96
|
-
...(assigneeId !== "__none" ? { assigneeId } : {}),
|
|
97
|
-
});
|
|
98
|
-
setTitle("");
|
|
99
|
-
setAmount("");
|
|
100
|
-
setAssigneeId("__none");
|
|
101
|
-
} catch (error) {
|
|
102
|
-
setFormError(error instanceof Error ? error.message : String(error));
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const memberName = (userId?: string) =>
|
|
107
|
-
members?.find((m) => m.userId === userId)?.name ?? "—";
|
|
108
|
-
|
|
18
|
+
// The starter ships empty on purpose: this page is what the person watching
|
|
19
|
+
// the preview sees while their agent builds the first version. Replace this
|
|
20
|
+
// whole route with the real app — don't build around it.
|
|
21
|
+
function BlankCanvas() {
|
|
109
22
|
return (
|
|
110
|
-
<main className="
|
|
111
|
-
<
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
<SelectItem value="draft">Draft</SelectItem>
|
|
124
|
-
<SelectItem value="submitted">Submitted</SelectItem>
|
|
125
|
-
<SelectItem value="approved">Approved</SelectItem>
|
|
126
|
-
</SelectGroup>
|
|
127
|
-
</SelectContent>
|
|
128
|
-
</Select>
|
|
129
|
-
</header>
|
|
130
|
-
|
|
131
|
-
<Card>
|
|
132
|
-
<CardHeader>
|
|
133
|
-
<CardTitle>Add expense</CardTitle>
|
|
134
|
-
</CardHeader>
|
|
135
|
-
<CardContent>
|
|
136
|
-
<FieldGroup>
|
|
137
|
-
<div className="flex flex-wrap items-end gap-4">
|
|
138
|
-
<Field className="min-w-48 flex-1">
|
|
139
|
-
<FieldLabel htmlFor="title">Title</FieldLabel>
|
|
140
|
-
<Input
|
|
141
|
-
id="title"
|
|
142
|
-
value={title}
|
|
143
|
-
onChange={(e) => setTitle(e.target.value)}
|
|
144
|
-
placeholder="Team lunch"
|
|
145
|
-
/>
|
|
146
|
-
</Field>
|
|
147
|
-
<Field className="w-32">
|
|
148
|
-
<FieldLabel htmlFor="amount">Amount</FieldLabel>
|
|
149
|
-
<Input
|
|
150
|
-
id="amount"
|
|
151
|
-
type="number"
|
|
152
|
-
value={amount}
|
|
153
|
-
onChange={(e) => setAmount(e.target.value)}
|
|
154
|
-
placeholder="42.50"
|
|
155
|
-
/>
|
|
156
|
-
</Field>
|
|
157
|
-
<Field className="w-48">
|
|
158
|
-
<FieldLabel>Assignee</FieldLabel>
|
|
159
|
-
<Select value={assigneeId} onValueChange={setAssigneeId}>
|
|
160
|
-
<SelectTrigger>
|
|
161
|
-
<SelectValue placeholder="Unassigned" />
|
|
162
|
-
</SelectTrigger>
|
|
163
|
-
<SelectContent>
|
|
164
|
-
<SelectGroup>
|
|
165
|
-
<SelectItem value="__none">Unassigned</SelectItem>
|
|
166
|
-
{(members ?? []).map((m) => (
|
|
167
|
-
<SelectItem key={m.userId} value={m.userId}>
|
|
168
|
-
{m.name}
|
|
169
|
-
</SelectItem>
|
|
170
|
-
))}
|
|
171
|
-
</SelectGroup>
|
|
172
|
-
</SelectContent>
|
|
173
|
-
</Select>
|
|
174
|
-
</Field>
|
|
175
|
-
<Button onClick={addExpense}>
|
|
176
|
-
<PlusIcon data-icon="inline-start" />
|
|
177
|
-
Add
|
|
178
|
-
</Button>
|
|
179
|
-
</div>
|
|
180
|
-
{formError && <FieldError>{formError}</FieldError>}
|
|
181
|
-
</FieldGroup>
|
|
182
|
-
</CardContent>
|
|
183
|
-
</Card>
|
|
184
|
-
|
|
185
|
-
{status === "LoadingFirstPage" && expenses.length === 0 ? (
|
|
186
|
-
<div className="flex justify-center p-12">
|
|
187
|
-
<Spinner />
|
|
188
|
-
</div>
|
|
189
|
-
) : expenses.length === 0 ? (
|
|
190
|
-
<Empty>
|
|
191
|
-
<EmptyHeader>
|
|
192
|
-
<EmptyTitle>No expenses yet</EmptyTitle>
|
|
193
|
-
<EmptyDescription>
|
|
194
|
-
Add the first one above — it appears for every teammate in real
|
|
195
|
-
time.
|
|
196
|
-
</EmptyDescription>
|
|
197
|
-
</EmptyHeader>
|
|
198
|
-
</Empty>
|
|
199
|
-
) : (
|
|
200
|
-
<Card>
|
|
201
|
-
<CardContent>
|
|
202
|
-
<Table>
|
|
203
|
-
<TableHeader>
|
|
204
|
-
<TableRow>
|
|
205
|
-
<TableHead>Title</TableHead>
|
|
206
|
-
<TableHead>Amount</TableHead>
|
|
207
|
-
<TableHead>Status</TableHead>
|
|
208
|
-
<TableHead>Assignee</TableHead>
|
|
209
|
-
<TableHead className="w-40" />
|
|
210
|
-
</TableRow>
|
|
211
|
-
</TableHeader>
|
|
212
|
-
<TableBody>
|
|
213
|
-
{expenses.map((e) => (
|
|
214
|
-
<TableRow key={e._id}>
|
|
215
|
-
<TableCell className="font-medium">{e.title}</TableCell>
|
|
216
|
-
<TableCell>${e.amount.toFixed(2)}</TableCell>
|
|
217
|
-
<TableCell>
|
|
218
|
-
<Badge variant={STATUS_VARIANT[e.status]}>
|
|
219
|
-
{e.status}
|
|
220
|
-
</Badge>
|
|
221
|
-
</TableCell>
|
|
222
|
-
<TableCell>{memberName(e.assigneeId)}</TableCell>
|
|
223
|
-
<TableCell>
|
|
224
|
-
<div className="flex justify-end gap-2">
|
|
225
|
-
{NEXT_STATUS[e.status] && (
|
|
226
|
-
<Button
|
|
227
|
-
variant="outline"
|
|
228
|
-
size="sm"
|
|
229
|
-
onClick={() =>
|
|
230
|
-
update(e._id, { status: NEXT_STATUS[e.status]! })
|
|
231
|
-
}
|
|
232
|
-
>
|
|
233
|
-
<CheckIcon data-icon="inline-start" />
|
|
234
|
-
{NEXT_STATUS[e.status]}
|
|
235
|
-
</Button>
|
|
236
|
-
)}
|
|
237
|
-
<Button
|
|
238
|
-
variant="ghost"
|
|
239
|
-
size="sm"
|
|
240
|
-
onClick={() => remove(e._id)}
|
|
241
|
-
>
|
|
242
|
-
<TrashIcon data-icon="inline-start" />
|
|
243
|
-
Delete
|
|
244
|
-
</Button>
|
|
245
|
-
</div>
|
|
246
|
-
</TableCell>
|
|
247
|
-
</TableRow>
|
|
248
|
-
))}
|
|
249
|
-
</TableBody>
|
|
250
|
-
</Table>
|
|
251
|
-
{status === "CanLoadMore" && (
|
|
252
|
-
<div className="flex justify-center pt-4">
|
|
253
|
-
<Button variant="ghost" onClick={() => loadMore()}>
|
|
254
|
-
Load more
|
|
255
|
-
</Button>
|
|
256
|
-
</div>
|
|
257
|
-
)}
|
|
258
|
-
</CardContent>
|
|
259
|
-
</Card>
|
|
260
|
-
)}
|
|
23
|
+
<main className="grid min-h-dvh place-items-center p-6">
|
|
24
|
+
<Empty>
|
|
25
|
+
<EmptyHeader>
|
|
26
|
+
<EmptyMedia variant="icon">
|
|
27
|
+
<Sparkles />
|
|
28
|
+
</EmptyMedia>
|
|
29
|
+
<EmptyTitle>{app.name} is a blank canvas</EmptyTitle>
|
|
30
|
+
<EmptyDescription>
|
|
31
|
+
Your agent builds the first version right on this page — changes
|
|
32
|
+
appear live as it works.
|
|
33
|
+
</EmptyDescription>
|
|
34
|
+
</EmptyHeader>
|
|
35
|
+
</Empty>
|
|
261
36
|
</main>
|
|
262
37
|
);
|
|
263
38
|
}
|