@cloudgrid-io/mcp 0.12.0 → 0.13.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/package.json +1 -1
- package/src/corpus/capability-map.md +21 -2
- package/src/corpus/examples/crm/index.md +273 -0
- package/src/corpus/examples/kanban/index.md +256 -0
- package/src/corpus/templates/admin-dashboard/README.md +75 -0
- package/src/corpus/templates/admin-dashboard/cloudgrid.yaml +15 -0
- package/src/corpus/templates/admin-dashboard/index.md +78 -0
- package/src/corpus/templates/admin-dashboard/services/web/app/api/orders/route.js +64 -0
- package/src/corpus/templates/admin-dashboard/services/web/app/api/users/route.js +63 -0
- package/src/corpus/templates/admin-dashboard/services/web/app/layout.js +39 -0
- package/src/corpus/templates/admin-dashboard/services/web/app/page.js +73 -0
- package/src/corpus/templates/admin-dashboard/services/web/app/resource-table.js +94 -0
- package/src/corpus/templates/admin-dashboard/services/web/lib/db.js +32 -0
- package/src/corpus/templates/admin-dashboard/services/web/package.json +16 -0
- package/src/corpus/templates/api-docs/README.md +28 -0
- package/src/corpus/templates/api-docs/cloudgrid.yaml +10 -0
- package/src/corpus/templates/api-docs/index.html +136 -0
- package/src/corpus/templates/api-docs/index.md +30 -0
- package/src/corpus/templates/changelog/README.md +27 -0
- package/src/corpus/templates/changelog/cloudgrid.yaml +10 -0
- package/src/corpus/templates/changelog/index.html +80 -0
- package/src/corpus/templates/changelog/index.md +30 -0
- package/src/corpus/templates/crm/README.md +72 -0
- package/src/corpus/templates/crm/cloudgrid.yaml +15 -0
- package/src/corpus/templates/crm/index.md +68 -0
- package/src/corpus/templates/crm/services/web/app/api/contacts/route.js +80 -0
- package/src/corpus/templates/crm/services/web/app/contact-manager.js +102 -0
- package/src/corpus/templates/crm/services/web/app/layout.js +35 -0
- package/src/corpus/templates/crm/services/web/app/page.js +34 -0
- package/src/corpus/templates/crm/services/web/lib/db.js +32 -0
- package/src/corpus/templates/crm/services/web/package.json +16 -0
- package/src/corpus/templates/docs-site/README.md +28 -0
- package/src/corpus/templates/docs-site/cloudgrid.yaml +10 -0
- package/src/corpus/templates/docs-site/index.html +122 -0
- package/src/corpus/templates/docs-site/index.md +30 -0
- package/src/corpus/templates/kanban/README.md +72 -0
- package/src/corpus/templates/kanban/cloudgrid.yaml +15 -0
- package/src/corpus/templates/kanban/index.md +68 -0
- package/src/corpus/templates/kanban/services/web/app/api/cards/route.js +70 -0
- package/src/corpus/templates/kanban/services/web/app/board.js +93 -0
- package/src/corpus/templates/kanban/services/web/app/layout.js +37 -0
- package/src/corpus/templates/kanban/services/web/app/page.js +32 -0
- package/src/corpus/templates/kanban/services/web/lib/db.js +32 -0
- package/src/corpus/templates/kanban/services/web/package.json +16 -0
- package/src/corpus/templates/portfolio/README.md +29 -0
- package/src/corpus/templates/portfolio/cloudgrid.yaml +10 -0
- package/src/corpus/templates/portfolio/index.html +126 -0
- package/src/corpus/templates/portfolio/index.md +30 -0
- package/src/corpus/templates/saas-marketing/README.md +30 -0
- package/src/corpus/templates/saas-marketing/cloudgrid.yaml +10 -0
- package/src/corpus/templates/saas-marketing/index.html +212 -0
- package/src/corpus/templates/saas-marketing/index.md +30 -0
- package/src/corpus/templates/status-page/README.md +28 -0
- package/src/corpus/templates/status-page/cloudgrid.yaml +10 -0
- package/src/corpus/templates/status-page/index.html +117 -0
- package/src/corpus/templates/status-page/index.md +30 -0
- package/src/corpus/templates/task-manager/README.md +72 -0
- package/src/corpus/templates/task-manager/cloudgrid.yaml +15 -0
- package/src/corpus/templates/task-manager/index.md +68 -0
- package/src/corpus/templates/task-manager/services/web/app/api/tasks/route.js +77 -0
- package/src/corpus/templates/task-manager/services/web/app/layout.js +39 -0
- package/src/corpus/templates/task-manager/services/web/app/page.js +33 -0
- package/src/corpus/templates/task-manager/services/web/app/task-list.js +110 -0
- package/src/corpus/templates/task-manager/services/web/lib/db.js +32 -0
- package/src/corpus/templates/task-manager/services/web/package.json +16 -0
- package/src/corpus/templates/waitlist/README.md +38 -0
- package/src/corpus/templates/waitlist/cloudgrid.yaml +10 -0
- package/src/corpus/templates/waitlist/index.html +69 -0
- package/src/corpus/templates/waitlist/index.md +41 -0
- package/src/corpus/workflows/admin-dashboard.md +102 -0
- package/src/corpus/workflows/api-docs.md +36 -0
- package/src/corpus/workflows/changelog.md +33 -0
- package/src/corpus/workflows/crm.md +101 -0
- package/src/corpus/workflows/docs-site.md +35 -0
- package/src/corpus/workflows/kanban.md +102 -0
- package/src/corpus/workflows/portfolio.md +35 -0
- package/src/corpus/workflows/saas-marketing.md +37 -0
- package/src/corpus/workflows/status-page.md +35 -0
- package/src/corpus/workflows/task-manager.md +101 -0
- package/src/corpus/workflows/waitlist.md +42 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Template: admin-dashboard (persistent Next.js + Mongo admin panel)
|
|
2
|
+
|
|
3
|
+
A real, deployable back-office. It manages two generic resources (`users` and
|
|
4
|
+
`orders`) in the grid-shared MongoDB, so records survive refresh and are shared
|
|
5
|
+
across sessions. Same proven shape as `app-with-data`, adapted to a metrics
|
|
6
|
+
header (counts + revenue) over a couple of collections plus a management table
|
|
7
|
+
with add/delete.
|
|
8
|
+
|
|
9
|
+
**Key rules (all proven by the app-with-data end-to-end deploy):**
|
|
10
|
+
|
|
11
|
+
1. **Service code MUST live under `services/<name>/`, not the repo/template
|
|
12
|
+
root.** `path:` in `cloudgrid.yaml` is the URL mount, NOT the filesystem path.
|
|
13
|
+
The service named `web` → the CLI looks for `services/web/`. App files at the
|
|
14
|
+
root fail with `Error: Service directory not found: …/services/web`.
|
|
15
|
+
2. **Read the DB connection string LAZILY (inside the getter), never at module
|
|
16
|
+
top level.** The grid injects it as `DATABASE_MONGODB_URL` (plus the legacy
|
|
17
|
+
`MONGODB_URL` alias) at dev-time and runtime; the app reads
|
|
18
|
+
`process.env.DATABASE_MONGODB_URL || process.env.MONGODB_URL` inside `getDb`.
|
|
19
|
+
A top-level read fails `next build`. Never hardcode a connection string; never
|
|
20
|
+
commit a secret.
|
|
21
|
+
3. **Declare the datastore with `needs: { database: true }`.** The deployer
|
|
22
|
+
provisions Mongo and injects the connection string. `requires:` is the
|
|
23
|
+
deprecated v1 alias; don't author new yaml with it, and never set `needs:` and
|
|
24
|
+
`requires:` together (the validator rejects the combination).
|
|
25
|
+
|
|
26
|
+
## File tree
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
cloudgrid.yaml # name + services.web (nextjs) + needs: { database: true }
|
|
30
|
+
services/web/package.json # next, react, react-dom, mongodb driver only
|
|
31
|
+
services/web/lib/db.js # lazy Mongo client from DATABASE_MONGODB_URL (legacy MONGODB_URL fallback)
|
|
32
|
+
services/web/app/layout.js # root layout + inline CSS
|
|
33
|
+
services/web/app/page.js # server component: reads users + orders, computes metrics
|
|
34
|
+
services/web/app/resource-table.js # generic client table (POST add / DELETE remove), reused per resource
|
|
35
|
+
services/web/app/api/users/route.js # GET / POST / DELETE on the users collection
|
|
36
|
+
services/web/app/api/orders/route.js # GET / POST / DELETE on the orders collection
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## cloudgrid.yaml
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
name: my-admin
|
|
43
|
+
services:
|
|
44
|
+
web:
|
|
45
|
+
type: nextjs
|
|
46
|
+
path: /
|
|
47
|
+
needs:
|
|
48
|
+
database: true
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> **Capability:** this template's need is `database: true`. The deployer
|
|
52
|
+
> provisions Mongo and injects `DATABASE_MONGODB_URL` (plus the legacy
|
|
53
|
+
> `MONGODB_URL` alias). `requires:` is the deprecated v1 alias — don't mix it with
|
|
54
|
+
> `needs:` (the validator rejects the combination). See the capability-map for
|
|
55
|
+
> the full injection table.
|
|
56
|
+
|
|
57
|
+
## The collections
|
|
58
|
+
|
|
59
|
+
Two generic resources, each its own Mongo collection and App-Router route:
|
|
60
|
+
|
|
61
|
+
- `users` — `{ name, email, role, createdAt }` (role: admin / member / viewer).
|
|
62
|
+
- `orders` — `{ customer, amount, status, createdAt }` (status: pending / paid /
|
|
63
|
+
shipped).
|
|
64
|
+
|
|
65
|
+
The page is a server component that reads both, computes a metrics header (user
|
|
66
|
+
count, order count, total revenue), and renders a reusable client
|
|
67
|
+
`ResourceTable` per resource. Each route does GET (list), POST (add), DELETE
|
|
68
|
+
(remove).
|
|
69
|
+
|
|
70
|
+
## Adapt it
|
|
71
|
+
|
|
72
|
+
- Rename or replace the `users` / `orders` collections and routes with your own
|
|
73
|
+
resources.
|
|
74
|
+
- Add or change fields by editing the `columns` config passed to `ResourceTable`
|
|
75
|
+
in `services/web/app/page.js` and the matching route's document shape.
|
|
76
|
+
- Add PATCH (edit) or extra metrics as the panel grows.
|
|
77
|
+
- Add `cache: true` to `needs:` only if you actually need Redis.
|
|
78
|
+
- Run `grid dev` to test locally, `grid plug` to deploy (async — poll to live).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// App Router route handler for the `orders` collection.
|
|
2
|
+
// GET -> list orders, POST -> add an order, DELETE -> remove an order by id.
|
|
3
|
+
//
|
|
4
|
+
// This is the second of two generic admin resources (see also api/users). To
|
|
5
|
+
// adapt, rename the collection or change the fields.
|
|
6
|
+
import { NextResponse } from "next/server";
|
|
7
|
+
import { ObjectId } from "mongodb";
|
|
8
|
+
import { getDb } from "../../../lib/db.js";
|
|
9
|
+
|
|
10
|
+
// Never cache — always read/write live data.
|
|
11
|
+
export const dynamic = "force-dynamic";
|
|
12
|
+
|
|
13
|
+
const STATUSES = ["pending", "paid", "shipped"];
|
|
14
|
+
|
|
15
|
+
async function orders() {
|
|
16
|
+
const db = await getDb();
|
|
17
|
+
return db.collection("orders");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function shape(o) {
|
|
21
|
+
return {
|
|
22
|
+
id: o._id.toString(),
|
|
23
|
+
customer: o.customer || "",
|
|
24
|
+
amount: typeof o.amount === "number" ? o.amount : 0,
|
|
25
|
+
status: STATUSES.includes(o.status) ? o.status : "pending",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// GET /api/orders — list all orders, newest first.
|
|
30
|
+
export async function GET() {
|
|
31
|
+
const col = await orders();
|
|
32
|
+
const items = await col.find({}).sort({ createdAt: -1 }).toArray();
|
|
33
|
+
return NextResponse.json(items.map(shape));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// POST /api/orders — add an order. Body: { customer, amount, status }.
|
|
37
|
+
export async function POST(request) {
|
|
38
|
+
const body = await request.json().catch(() => ({}));
|
|
39
|
+
const customer = typeof body.customer === "string" ? body.customer.trim() : "";
|
|
40
|
+
if (!customer) {
|
|
41
|
+
return NextResponse.json({ error: "customer is required" }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
const amount = Number(body.amount);
|
|
44
|
+
const doc = {
|
|
45
|
+
customer,
|
|
46
|
+
amount: Number.isFinite(amount) ? amount : 0,
|
|
47
|
+
status: STATUSES.includes(body.status) ? body.status : "pending",
|
|
48
|
+
createdAt: new Date(),
|
|
49
|
+
};
|
|
50
|
+
const col = await orders();
|
|
51
|
+
const res = await col.insertOne(doc);
|
|
52
|
+
return NextResponse.json({ id: res.insertedId.toString(), ...doc }, { status: 201 });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// DELETE /api/orders?id=<id> — remove an order.
|
|
56
|
+
export async function DELETE(request) {
|
|
57
|
+
const id = new URL(request.url).searchParams.get("id");
|
|
58
|
+
if (!id || !ObjectId.isValid(id)) {
|
|
59
|
+
return NextResponse.json({ error: "valid id is required" }, { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
const col = await orders();
|
|
62
|
+
await col.deleteOne({ _id: new ObjectId(id) });
|
|
63
|
+
return NextResponse.json({ ok: true });
|
|
64
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// App Router route handler for the `users` collection.
|
|
2
|
+
// GET -> list users, POST -> add a user, DELETE -> remove a user by id.
|
|
3
|
+
//
|
|
4
|
+
// This is one of two generic admin resources (see also api/orders). To adapt,
|
|
5
|
+
// rename the collection or change the fields.
|
|
6
|
+
import { NextResponse } from "next/server";
|
|
7
|
+
import { ObjectId } from "mongodb";
|
|
8
|
+
import { getDb } from "../../../lib/db.js";
|
|
9
|
+
|
|
10
|
+
// Never cache — always read/write live data.
|
|
11
|
+
export const dynamic = "force-dynamic";
|
|
12
|
+
|
|
13
|
+
const ROLES = ["admin", "member", "viewer"];
|
|
14
|
+
|
|
15
|
+
async function users() {
|
|
16
|
+
const db = await getDb();
|
|
17
|
+
return db.collection("users");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function shape(u) {
|
|
21
|
+
return {
|
|
22
|
+
id: u._id.toString(),
|
|
23
|
+
name: u.name || "",
|
|
24
|
+
email: u.email || "",
|
|
25
|
+
role: ROLES.includes(u.role) ? u.role : "member",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// GET /api/users — list all users, newest first.
|
|
30
|
+
export async function GET() {
|
|
31
|
+
const col = await users();
|
|
32
|
+
const items = await col.find({}).sort({ createdAt: -1 }).toArray();
|
|
33
|
+
return NextResponse.json(items.map(shape));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// POST /api/users — add a user. Body: { name, email, role }.
|
|
37
|
+
export async function POST(request) {
|
|
38
|
+
const body = await request.json().catch(() => ({}));
|
|
39
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
40
|
+
if (!name) {
|
|
41
|
+
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
const doc = {
|
|
44
|
+
name,
|
|
45
|
+
email: typeof body.email === "string" ? body.email.trim() : "",
|
|
46
|
+
role: ROLES.includes(body.role) ? body.role : "member",
|
|
47
|
+
createdAt: new Date(),
|
|
48
|
+
};
|
|
49
|
+
const col = await users();
|
|
50
|
+
const res = await col.insertOne(doc);
|
|
51
|
+
return NextResponse.json({ id: res.insertedId.toString(), ...doc }, { status: 201 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// DELETE /api/users?id=<id> — remove a user.
|
|
55
|
+
export async function DELETE(request) {
|
|
56
|
+
const id = new URL(request.url).searchParams.get("id");
|
|
57
|
+
if (!id || !ObjectId.isValid(id)) {
|
|
58
|
+
return NextResponse.json({ error: "valid id is required" }, { status: 400 });
|
|
59
|
+
}
|
|
60
|
+
const col = await users();
|
|
61
|
+
await col.deleteOne({ _id: new ObjectId(id) });
|
|
62
|
+
return NextResponse.json({ ok: true });
|
|
63
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Root layout + plain inline CSS (no external CDNs, fonts, or stylesheets).
|
|
2
|
+
export const metadata = {
|
|
3
|
+
title: "Admin dashboard",
|
|
4
|
+
description: "A persistent admin panel on CloudGrid, backed by grid-shared Mongo.",
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const css = `
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; line-height: 1.5; }
|
|
11
|
+
main { max-width: 60rem; margin: 3rem auto; padding: 0 1.25rem; }
|
|
12
|
+
h1 { margin: 0 0 .25rem; }
|
|
13
|
+
h2 { margin: 2rem 0 .75rem; font-size: 1.1rem; }
|
|
14
|
+
.hint { margin: 0 0 1.5rem; opacity: .7; font-size: .9rem; }
|
|
15
|
+
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: .75rem; margin-bottom: 1rem; }
|
|
16
|
+
.metric { border: 1px solid #8883; border-radius: .5rem; padding: .9rem 1rem; }
|
|
17
|
+
.metric .n { font-size: 1.8rem; font-weight: 600; }
|
|
18
|
+
.metric .l { font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; opacity: .7; }
|
|
19
|
+
.row { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; }
|
|
20
|
+
input, select { padding: .5rem .75rem; border: 1px solid #8886; border-radius: .5rem; font: inherit; }
|
|
21
|
+
input { flex: 1; min-width: 8rem; }
|
|
22
|
+
button { padding: .5rem .9rem; border: 1px solid #8886; border-radius: .5rem; background: #6663; font: inherit; cursor: pointer; }
|
|
23
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
24
|
+
table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
|
|
25
|
+
th, td { text-align: left; padding: .55rem .5rem; border-bottom: 1px solid #8883; }
|
|
26
|
+
th { font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; opacity: .7; }
|
|
27
|
+
.empty { opacity: .6; }
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
export default function RootLayout({ children }) {
|
|
31
|
+
return (
|
|
32
|
+
<html lang="en">
|
|
33
|
+
<head>
|
|
34
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
35
|
+
</head>
|
|
36
|
+
<body>{children}</body>
|
|
37
|
+
</html>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Home page: a server component reads two collections (users + orders) straight
|
|
2
|
+
// from Mongo, shows a metrics header (counts + revenue), and hands each resource
|
|
3
|
+
// to a client management table (add / delete). Data persists across refresh and
|
|
4
|
+
// across users because it lives in the grid-shared Mongo, not in memory.
|
|
5
|
+
import { getDb } from "../lib/db.js";
|
|
6
|
+
import ResourceTable from "./resource-table.js";
|
|
7
|
+
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
9
|
+
|
|
10
|
+
const USER_ROLES = ["admin", "member", "viewer"];
|
|
11
|
+
const ORDER_STATUSES = ["pending", "paid", "shipped"];
|
|
12
|
+
|
|
13
|
+
async function load() {
|
|
14
|
+
const db = await getDb();
|
|
15
|
+
const [users, orders] = await Promise.all([
|
|
16
|
+
db.collection("users").find({}).sort({ createdAt: -1 }).toArray(),
|
|
17
|
+
db.collection("orders").find({}).sort({ createdAt: -1 }).toArray(),
|
|
18
|
+
]);
|
|
19
|
+
return {
|
|
20
|
+
users: users.map((u) => ({
|
|
21
|
+
id: u._id.toString(),
|
|
22
|
+
name: u.name || "",
|
|
23
|
+
email: u.email || "",
|
|
24
|
+
role: USER_ROLES.includes(u.role) ? u.role : "member",
|
|
25
|
+
})),
|
|
26
|
+
orders: orders.map((o) => ({
|
|
27
|
+
id: o._id.toString(),
|
|
28
|
+
customer: o.customer || "",
|
|
29
|
+
amount: typeof o.amount === "number" ? o.amount : 0,
|
|
30
|
+
status: ORDER_STATUSES.includes(o.status) ? o.status : "pending",
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default async function Page() {
|
|
36
|
+
const { users, orders } = await load();
|
|
37
|
+
const revenue = orders.reduce((sum, o) => sum + (o.amount || 0), 0);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<main>
|
|
41
|
+
<h1>Admin</h1>
|
|
42
|
+
<p className="hint">Persisted in the grid-shared Mongo — survives refresh.</p>
|
|
43
|
+
|
|
44
|
+
<div className="metrics">
|
|
45
|
+
<div className="metric"><div className="n">{users.length}</div><div className="l">Users</div></div>
|
|
46
|
+
<div className="metric"><div className="n">{orders.length}</div><div className="l">Orders</div></div>
|
|
47
|
+
<div className="metric"><div className="n">${revenue.toLocaleString()}</div><div className="l">Revenue</div></div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<h2>Users</h2>
|
|
51
|
+
<ResourceTable
|
|
52
|
+
resource="users"
|
|
53
|
+
columns={[
|
|
54
|
+
{ key: "name", label: "Name", input: "text", required: true },
|
|
55
|
+
{ key: "email", label: "Email", input: "text" },
|
|
56
|
+
{ key: "role", label: "Role", input: "select", options: USER_ROLES },
|
|
57
|
+
]}
|
|
58
|
+
initialRows={users}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<h2>Orders</h2>
|
|
62
|
+
<ResourceTable
|
|
63
|
+
resource="orders"
|
|
64
|
+
columns={[
|
|
65
|
+
{ key: "customer", label: "Customer", input: "text", required: true },
|
|
66
|
+
{ key: "amount", label: "Amount", input: "number" },
|
|
67
|
+
{ key: "status", label: "Status", input: "select", options: ORDER_STATUSES },
|
|
68
|
+
]}
|
|
69
|
+
initialRows={orders}
|
|
70
|
+
/>
|
|
71
|
+
</main>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Generic client management table for one admin resource. Renders a row of
|
|
4
|
+
// inputs (driven by `columns`) and the current rows, and posts to
|
|
5
|
+
// /api/<resource> (POST to add, DELETE to remove). Reused for users + orders.
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
|
|
8
|
+
function blankDraft(columns) {
|
|
9
|
+
const d = {};
|
|
10
|
+
for (const c of columns) d[c.key] = c.input === "select" ? c.options[0] : "";
|
|
11
|
+
return d;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function ResourceTable({ resource, columns, initialRows }) {
|
|
15
|
+
const [rows, setRows] = useState(initialRows);
|
|
16
|
+
const [draft, setDraft] = useState(() => blankDraft(columns));
|
|
17
|
+
const [busy, setBusy] = useState(false);
|
|
18
|
+
|
|
19
|
+
function set(key, value) {
|
|
20
|
+
setDraft((prev) => ({ ...prev, [key]: value }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function add(e) {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
const required = columns.find((c) => c.required);
|
|
26
|
+
if (required && !String(draft[required.key]).trim()) return;
|
|
27
|
+
setBusy(true);
|
|
28
|
+
const res = await fetch(`/api/${resource}`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "content-type": "application/json" },
|
|
31
|
+
body: JSON.stringify(draft),
|
|
32
|
+
});
|
|
33
|
+
if (res.ok) {
|
|
34
|
+
const row = await res.json();
|
|
35
|
+
setRows((prev) => [row, ...prev]);
|
|
36
|
+
setDraft(blankDraft(columns));
|
|
37
|
+
}
|
|
38
|
+
setBusy(false);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function remove(id) {
|
|
42
|
+
setBusy(true);
|
|
43
|
+
const res = await fetch(`/api/${resource}?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
44
|
+
if (res.ok) setRows((prev) => prev.filter((r) => r.id !== id));
|
|
45
|
+
setBusy(false);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div>
|
|
50
|
+
<form onSubmit={add} className="row">
|
|
51
|
+
{columns.map((c) =>
|
|
52
|
+
c.input === "select" ? (
|
|
53
|
+
<select key={c.key} value={draft[c.key]} onChange={(e) => set(c.key, e.target.value)} aria-label={c.label}>
|
|
54
|
+
{c.options.map((o) => (
|
|
55
|
+
<option key={o} value={o}>{o}</option>
|
|
56
|
+
))}
|
|
57
|
+
</select>
|
|
58
|
+
) : (
|
|
59
|
+
<input
|
|
60
|
+
key={c.key}
|
|
61
|
+
type={c.input === "number" ? "number" : "text"}
|
|
62
|
+
value={draft[c.key]}
|
|
63
|
+
onChange={(e) => set(c.key, e.target.value)}
|
|
64
|
+
placeholder={c.label}
|
|
65
|
+
aria-label={c.label}
|
|
66
|
+
/>
|
|
67
|
+
),
|
|
68
|
+
)}
|
|
69
|
+
<button type="submit" disabled={busy}>Add</button>
|
|
70
|
+
</form>
|
|
71
|
+
<table>
|
|
72
|
+
<thead>
|
|
73
|
+
<tr>
|
|
74
|
+
{columns.map((c) => <th key={c.key}>{c.label}</th>)}
|
|
75
|
+
<th></th>
|
|
76
|
+
</tr>
|
|
77
|
+
</thead>
|
|
78
|
+
<tbody>
|
|
79
|
+
{rows.length === 0 && (
|
|
80
|
+
<tr><td className="empty" colSpan={columns.length + 1}>No {resource} yet.</td></tr>
|
|
81
|
+
)}
|
|
82
|
+
{rows.map((r) => (
|
|
83
|
+
<tr key={r.id}>
|
|
84
|
+
{columns.map((c) => <td key={c.key}>{String(r[c.key] ?? "")}</td>)}
|
|
85
|
+
<td>
|
|
86
|
+
<button type="button" onClick={() => remove(r.id)} disabled={busy}>Delete</button>
|
|
87
|
+
</td>
|
|
88
|
+
</tr>
|
|
89
|
+
))}
|
|
90
|
+
</tbody>
|
|
91
|
+
</table>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Cached MongoDB client for the App Router.
|
|
2
|
+
//
|
|
3
|
+
// The grid injects the connection string as the DATABASE_MONGODB_URL environment
|
|
4
|
+
// variable (plus the legacy MONGODB_URL alias) at dev-time (`grid dev`) and
|
|
5
|
+
// runtime (after `grid plug`). Read the canonical var first, fall back to the
|
|
6
|
+
// legacy alias. Never hardcode a connection string here and never commit a secret.
|
|
7
|
+
//
|
|
8
|
+
// The env var and client are resolved LAZILY (inside getDb), never at module top
|
|
9
|
+
// level — otherwise `next build` throws when it imports this module for route
|
|
10
|
+
// analysis, before the grid injects the var.
|
|
11
|
+
import { MongoClient } from "mongodb";
|
|
12
|
+
|
|
13
|
+
function clientPromise() {
|
|
14
|
+
const uri = process.env.DATABASE_MONGODB_URL || process.env.MONGODB_URL;
|
|
15
|
+
if (!uri) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"DATABASE_MONGODB_URL is not set. The grid injects it automatically — run " +
|
|
18
|
+
"this app with `grid dev` locally, or deploy it with `grid plug` (the grid " +
|
|
19
|
+
"injects the DB connection string at runtime). Do not set it by hand.",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (!globalThis.__mongoClientPromise) {
|
|
23
|
+
globalThis.__mongoClientPromise = new MongoClient(uri).connect();
|
|
24
|
+
}
|
|
25
|
+
return globalThis.__mongoClientPromise;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getDb() {
|
|
29
|
+
const client = await clientPromise();
|
|
30
|
+
// The default DB comes from the connection-string path segment the grid injects.
|
|
31
|
+
return client.db();
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-admin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"next": "^15.1.0",
|
|
12
|
+
"react": "^19.0.0",
|
|
13
|
+
"react-dom": "^19.0.0",
|
|
14
|
+
"mongodb": "^6.12.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# api-docs
|
|
2
|
+
|
|
3
|
+
A single self-contained HTML API reference: a sidebar endpoint list, per-endpoint method badges (GET / POST / PUT / DELETE), a params table, and request / response examples in code blocks.
|
|
4
|
+
|
|
5
|
+
Static inspiration — a single self-contained `index.html` (inline CSS and JS,
|
|
6
|
+
no external CDNs). It deploys instantly via `gridctl_drop` and works on every
|
|
7
|
+
CloudGrid edition, including hosted.
|
|
8
|
+
|
|
9
|
+
## Fill it in
|
|
10
|
+
|
|
11
|
+
Fetch the template with `gridctl_fetch("template", "api-docs")`, then replace the
|
|
12
|
+
placeholders:
|
|
13
|
+
|
|
14
|
+
- The API name, base URL, and auth note.
|
|
15
|
+
- One section per endpoint: method badge, path, description, a params table, and request/response example blocks.
|
|
16
|
+
- The sidebar links jump to each endpoint anchor; the filter box narrows them client-side (inline JS).
|
|
17
|
+
- The code blocks are plain `<pre><code>` — no external highlighter or CDN.
|
|
18
|
+
|
|
19
|
+
Keep it a single self-contained HTML file. Do not add external scripts,
|
|
20
|
+
stylesheets, fonts, or large embedded media.
|
|
21
|
+
|
|
22
|
+
## Deploy
|
|
23
|
+
|
|
24
|
+
- Hosted MCP edition: call the drop tool with the HTML.
|
|
25
|
+
- Local MCP / CLI edition: write the HTML to a file and run `gridctl plug`.
|
|
26
|
+
|
|
27
|
+
It deploys as an inspiration and returns a live share URL right away. Re-deploy
|
|
28
|
+
with the same entity id to update the same URL.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# static — deploys as an inspiration (instant, works on hosted) via gridctl_drop;
|
|
2
|
+
# this yaml is only for plugging it as an OWNED static runtime (async, local
|
|
3
|
+
# edition only). To publish the page, prefer gridctl_drop, not grid plug.
|
|
4
|
+
#
|
|
5
|
+
# needs: {} # none — static, no infrastructure
|
|
6
|
+
name: api-docs
|
|
7
|
+
services:
|
|
8
|
+
web:
|
|
9
|
+
type: static
|
|
10
|
+
path: /
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{API_NAME}} API reference</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0b0f17; --panel: #121826; --fg: #e8edf5; --muted: #9aa7bd;
|
|
10
|
+
--accent: #5b8cff; --border: #223049; --code-bg: #0f1523;
|
|
11
|
+
--get: #34d399; --post: #5b8cff; --put: #fbbf24; --delete: #f87171;
|
|
12
|
+
}
|
|
13
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
14
|
+
html { scroll-behavior: smooth; }
|
|
15
|
+
body {
|
|
16
|
+
background: var(--bg); color: var(--fg);
|
|
17
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
18
|
+
-webkit-font-smoothing: antialiased; line-height: 1.6;
|
|
19
|
+
}
|
|
20
|
+
.layout { display: grid; grid-template-columns: 250px 1fr; min-height: 100vh; }
|
|
21
|
+
aside { border-right: 1px solid var(--border); padding: 1.5rem 1.25rem; position: sticky; top: 0; align-self: start; height: 100vh; overflow-y: auto; }
|
|
22
|
+
aside .brand { font-weight: 600; letter-spacing: -0.01em; display: block; margin-bottom: 1rem; }
|
|
23
|
+
.filter { width: 100%; padding: 0.55rem 0.75rem; border-radius: 8px; border: 1px solid var(--border); background: var(--code-bg); color: var(--fg); font-size: 0.9rem; margin-bottom: 1.25rem; }
|
|
24
|
+
.filter::placeholder { color: var(--muted); }
|
|
25
|
+
nav.eps { display: grid; gap: 0.15rem; }
|
|
26
|
+
nav.eps a { display: flex; align-items: center; gap: 0.5rem; color: var(--muted); text-decoration: none; padding: 0.35rem 0.5rem; border-radius: 6px; font-size: 0.88rem; }
|
|
27
|
+
nav.eps a:hover { background: var(--panel); color: var(--fg); }
|
|
28
|
+
.method { flex: none; font-size: 0.65rem; font-weight: 700; padding: 0.1rem 0.4rem; border-radius: 4px; letter-spacing: 0.03em; }
|
|
29
|
+
.method.get { color: var(--get); background: rgba(52,211,153,0.14); }
|
|
30
|
+
.method.post { color: var(--post); background: rgba(91,140,255,0.14); }
|
|
31
|
+
.method.put { color: var(--put); background: rgba(251,191,36,0.14); }
|
|
32
|
+
.method.delete { color: var(--delete); background: rgba(248,113,113,0.14); }
|
|
33
|
+
|
|
34
|
+
main { padding: clamp(1.5rem, 4vw, 3rem); max-width: 860px; }
|
|
35
|
+
main h1 { font-size: clamp(2rem, 5vw, 2.6rem); letter-spacing: -0.02em; margin-bottom: 0.5rem; }
|
|
36
|
+
main .lede { color: var(--muted); margin-bottom: 1rem; }
|
|
37
|
+
.base { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.9rem; font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-size: 0.9rem; margin-bottom: 2.5rem; display: inline-block; }
|
|
38
|
+
|
|
39
|
+
.endpoint { padding-top: 2rem; margin-bottom: 2.5rem; border-top: 1px solid var(--border); }
|
|
40
|
+
.endpoint .sig { display: flex; align-items: center; gap: 0.65rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
|
|
41
|
+
.endpoint .sig .path { font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-size: 1.05rem; }
|
|
42
|
+
.endpoint p { color: var(--muted); margin-bottom: 1rem; }
|
|
43
|
+
h3 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin: 1.5rem 0 0.6rem; }
|
|
44
|
+
table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: 0.9rem; }
|
|
45
|
+
th, td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); }
|
|
46
|
+
th { color: var(--muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
|
47
|
+
td code { background: var(--code-bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.1rem 0.35rem; }
|
|
48
|
+
.table-scroll { overflow-x: auto; }
|
|
49
|
+
pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 10px; padding: 1rem 1.1rem; overflow-x: auto; margin-bottom: 1rem; }
|
|
50
|
+
code { font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-size: 0.88rem; }
|
|
51
|
+
@media (max-width: 720px) {
|
|
52
|
+
.layout { grid-template-columns: 1fr; }
|
|
53
|
+
aside { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
|
|
54
|
+
}
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<div class="layout">
|
|
59
|
+
<aside>
|
|
60
|
+
<span class="brand">{{API_NAME}} API</span>
|
|
61
|
+
<input class="filter" id="filter" type="search" placeholder="Filter endpoints" aria-label="Filter endpoints" />
|
|
62
|
+
<nav class="eps" id="eps">
|
|
63
|
+
<!-- NAV_START -->
|
|
64
|
+
<a href="#list-items"><span class="method get">GET</span>List items</a>
|
|
65
|
+
<a href="#create-item"><span class="method post">POST</span>Create item</a>
|
|
66
|
+
<a href="#update-item"><span class="method put">PUT</span>Update item</a>
|
|
67
|
+
<a href="#delete-item"><span class="method delete">DEL</span>Delete item</a>
|
|
68
|
+
<!-- Add more <a> links; keep them matching endpoint anchor ids. -->
|
|
69
|
+
<!-- NAV_END -->
|
|
70
|
+
</nav>
|
|
71
|
+
</aside>
|
|
72
|
+
|
|
73
|
+
<main>
|
|
74
|
+
<h1>{{API_NAME}} API reference</h1>
|
|
75
|
+
<p class="lede">{{API_LEDE}}</p>
|
|
76
|
+
<div class="base">Base URL: {{BASE_URL}}</div>
|
|
77
|
+
<p class="lede" style="margin-top:-1.5rem">{{AUTH_NOTE}}</p>
|
|
78
|
+
|
|
79
|
+
<!-- ENDPOINTS_START -->
|
|
80
|
+
<section class="endpoint" id="list-items">
|
|
81
|
+
<div class="sig"><span class="method get">GET</span><span class="path">{{EP_1_PATH}}</span></div>
|
|
82
|
+
<p>{{EP_1_DESC}}</p>
|
|
83
|
+
<h3>Query parameters</h3>
|
|
84
|
+
<div class="table-scroll"><table>
|
|
85
|
+
<tr><th>Name</th><th>Type</th><th>Description</th></tr>
|
|
86
|
+
<tr><td><code>{{EP_1_PARAM_1}}</code></td><td>{{EP_1_PARAM_1_TYPE}}</td><td>{{EP_1_PARAM_1_DESC}}</td></tr>
|
|
87
|
+
<tr><td><code>{{EP_1_PARAM_2}}</code></td><td>{{EP_1_PARAM_2_TYPE}}</td><td>{{EP_1_PARAM_2_DESC}}</td></tr>
|
|
88
|
+
</table></div>
|
|
89
|
+
<h3>Response</h3>
|
|
90
|
+
<pre><code>{{EP_1_RESPONSE}}</code></pre>
|
|
91
|
+
</section>
|
|
92
|
+
|
|
93
|
+
<section class="endpoint" id="create-item">
|
|
94
|
+
<div class="sig"><span class="method post">POST</span><span class="path">{{EP_2_PATH}}</span></div>
|
|
95
|
+
<p>{{EP_2_DESC}}</p>
|
|
96
|
+
<h3>Request body</h3>
|
|
97
|
+
<pre><code>{{EP_2_REQUEST}}</code></pre>
|
|
98
|
+
<h3>Response</h3>
|
|
99
|
+
<pre><code>{{EP_2_RESPONSE}}</code></pre>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<section class="endpoint" id="update-item">
|
|
103
|
+
<div class="sig"><span class="method put">PUT</span><span class="path">{{EP_3_PATH}}</span></div>
|
|
104
|
+
<p>{{EP_3_DESC}}</p>
|
|
105
|
+
<h3>Request body</h3>
|
|
106
|
+
<pre><code>{{EP_3_REQUEST}}</code></pre>
|
|
107
|
+
<h3>Response</h3>
|
|
108
|
+
<pre><code>{{EP_3_RESPONSE}}</code></pre>
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section class="endpoint" id="delete-item">
|
|
112
|
+
<div class="sig"><span class="method delete">DELETE</span><span class="path">{{EP_4_PATH}}</span></div>
|
|
113
|
+
<p>{{EP_4_DESC}}</p>
|
|
114
|
+
<h3>Response</h3>
|
|
115
|
+
<pre><code>{{EP_4_RESPONSE}}</code></pre>
|
|
116
|
+
</section>
|
|
117
|
+
<!-- Add more .endpoint sections (with matching nav links) as needed. -->
|
|
118
|
+
<!-- ENDPOINTS_END -->
|
|
119
|
+
</main>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<script>
|
|
123
|
+
// Client-side endpoint filter. No network, no dependencies.
|
|
124
|
+
(function () {
|
|
125
|
+
var filter = document.getElementById("filter");
|
|
126
|
+
var links = Array.prototype.slice.call(document.querySelectorAll("#eps a"));
|
|
127
|
+
filter.addEventListener("input", function () {
|
|
128
|
+
var q = filter.value.trim().toLowerCase();
|
|
129
|
+
links.forEach(function (a) {
|
|
130
|
+
a.style.display = a.textContent.toLowerCase().indexOf(q) !== -1 ? "" : "none";
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
})();
|
|
134
|
+
</script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|