makersclaw 0.5.2 → 0.7.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/README.md +73 -7
- package/package.json +3 -2
- package/r/annotate.json +4 -4
- package/r/approval-request.json +3 -3
- package/r/audio-player.json +3 -3
- package/r/auth-form.json +2 -2
- package/r/board.json +4 -4
- package/r/calendar-view.json +4 -4
- package/r/cart.json +3 -3
- package/r/chart.json +3 -3
- package/r/chat.json +3 -3
- package/r/code-editor.json +4 -4
- package/r/comment-box.json +4 -4
- package/r/contact-section.json +3 -3
- package/r/core.json +3 -3
- package/r/csv-import.json +4 -4
- package/r/data-table.json +3 -3
- package/r/diagram.json +3 -3
- package/r/doc-editor.json +4 -4
- package/r/doc-page.json +4 -4
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +3 -3
- package/r/flow-canvas.json +4 -4
- package/r/footer.json +3 -3
- package/r/gantt.json +4 -4
- package/r/job-panel.json +3 -3
- package/r/json-editor.json +4 -4
- package/r/map.json +5 -5
- package/r/menubar.json +2 -2
- package/r/mind-map.json +4 -4
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +6 -6
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +6 -6
- package/r/product-detail.json +3 -3
- package/r/product-grid.json +4 -4
- package/r/record-form.json +3 -3
- package/r/record-notes.json +5 -5
- package/r/registry.json +121 -121
- package/r/search-dialog.json +3 -3
- package/r/settings-list.json +3 -3
- package/r/setup-checklist.json +3 -3
- package/r/share-dialog.json +3 -3
- package/r/sidebar.json +2 -2
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +5 -5
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +4 -4
- package/r/task-list.json +4 -4
- package/r/task-model.json +3 -3
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +3 -3
- package/r/whiteboard.json +4 -4
- package/registry/blocks/annotate/README.md +14 -5
- package/registry/blocks/approval-request/README.md +15 -8
- package/registry/blocks/audio-player/README.md +17 -10
- package/registry/blocks/board/README.md +13 -9
- package/registry/blocks/board/board.jsx +1 -1
- package/registry/blocks/calendar-view/README.md +28 -4
- package/registry/blocks/cart/README.md +14 -10
- package/registry/blocks/chart/README.md +14 -7
- package/registry/blocks/chat/README.md +43 -13
- package/registry/blocks/code-editor/README.md +4 -0
- package/registry/blocks/code-editor/code-editor.jsx +1 -1
- package/registry/blocks/comment-box/README.md +20 -13
- package/registry/blocks/contact-section/README.md +7 -6
- package/registry/blocks/csv-import/README.md +34 -12
- package/registry/blocks/data-table/README.md +41 -8
- package/registry/blocks/diagram/diagram.jsx +1 -1
- package/registry/blocks/doc-editor/README.md +18 -6
- package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
- package/registry/blocks/doc-page/README.md +16 -6
- package/registry/blocks/file-upload/README.md +7 -1
- package/registry/blocks/flow-canvas/README.md +16 -5
- package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
- package/registry/blocks/footer/README.md +4 -2
- package/registry/blocks/gantt/README.md +18 -12
- package/registry/blocks/job-panel/README.md +1 -1
- package/registry/blocks/json-editor/README.md +16 -4
- package/registry/blocks/map/README.md +19 -3
- package/registry/blocks/map/map-view.jsx +1 -1
- package/registry/blocks/map/maplibre.js +1 -1
- package/registry/blocks/mind-map/README.md +16 -5
- package/registry/blocks/org-chart/README.md +10 -7
- package/registry/blocks/page-builder/README.md +13 -6
- package/registry/blocks/page-builder/page-builder.jsx +1 -1
- package/registry/blocks/page-builder/page-view.jsx +1 -1
- package/registry/blocks/page-builder/page.js +1 -1
- package/registry/blocks/pdf-viewer/README.md +22 -20
- package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
- package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
- package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
- package/registry/blocks/product-detail/README.md +13 -7
- package/registry/blocks/product-detail/demo.jsx +1 -1
- package/registry/blocks/product-grid/README.md +12 -10
- package/registry/blocks/record-form/README.md +26 -9
- package/registry/blocks/record-notes/README.md +42 -30
- package/registry/blocks/search-dialog/README.md +38 -7
- package/registry/blocks/settings-list/README.md +29 -17
- package/registry/blocks/setup-checklist/README.md +12 -14
- package/registry/blocks/share-dialog/README.md +1 -1
- package/registry/blocks/signature-pad/README.md +15 -4
- package/registry/blocks/sketch-canvas/README.md +13 -4
- package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
- package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
- package/registry/blocks/spreadsheet/README.md +17 -8
- package/registry/blocks/stat-cards/README.md +17 -8
- package/registry/blocks/task-list/README.md +14 -11
- package/registry/blocks/task-model/README.md +3 -3
- package/registry/blocks/transaction-list/README.md +15 -7
- package/registry/blocks/whiteboard/README.md +19 -13
- package/src/cli.mjs +65 -9
- package/src/install.mjs +360 -21
- package/src/lockfile.mjs +468 -0
- package/src/pm.mjs +473 -0
- package/src/registry.mjs +125 -1
- package/templates/next/browser/app/auto-refresh.jsx +67 -0
- package/templates/next/browser/app/layout.jsx +94 -0
- package/templates/next/browser/app/page.jsx +60 -0
- package/templates/next/browser/app/s/[key]/page.jsx +95 -0
- package/templates/next/browser/lib/actions.js +16 -0
- package/templates/next/browser/lib/data.js +43 -0
- package/templates/next/files/README.md +818 -82
- package/templates/next/files/app/claw-alive/route.js +2 -2
- package/templates/next/files/app/frame.js +134 -9
- package/templates/next/files/app/layout.jsx +2 -2
- package/templates/next/files/check/run.mjs +1491 -53
- package/templates/next/files/components/ui/item.jsx +2 -2
- package/templates/next/files/components/ui/notice.jsx +1 -1
- package/templates/next/files/components/ui/separator.jsx +1 -1
- package/templates/next/files/lib/actions.js +171 -52
- package/templates/next/files/lib/brain-server.js +96 -0
- package/templates/next/files/lib/brain.js +413 -0
- package/templates/next/files/lib/claw.js +11 -0
- package/templates/next/files/lib/data.js +120 -30
- package/templates/next/files/lib/db.js +97 -17
- package/templates/next/files/lib/door.js +680 -0
- package/templates/next/files/next.config.mjs +85 -9
- package/templates/next/files/package.json +3 -3
- package/templates/next/files/{middleware.js → proxy.js} +13 -6
- package/templates/next/files/surfaces.js +3 -2
- package/templates/next/pnpm-lock.yaml +5870 -0
- package/templates/next/template.json +13 -6
- package/templates/next/files/schema.sql +0 -22
package/README.md
CHANGED
|
@@ -6,9 +6,10 @@ The Makersclaw app template, its kit and its blocks, as one command.
|
|
|
6
6
|
npx makersclaw init my-app # lay the template
|
|
7
7
|
npx makersclaw search "kanban" # find a block by what it does
|
|
8
8
|
npx makersclaw add board # install it
|
|
9
|
+
npx makersclaw install # its packages, from the app's pnpm-lock.yaml
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
Every app Makersclaw builds starts from this template: Next.js
|
|
12
|
+
Every app Makersclaw builds starts from this template: Next.js 16 (app
|
|
12
13
|
router, JSX, server actions), Postgres, Tailwind v4 mapped to the
|
|
13
14
|
workspace's theme, the data layer and write actions, the `_claw` platform
|
|
14
15
|
client, and a kit of parts. The app designs its own screens on top.
|
|
@@ -52,7 +53,12 @@ them: [makersclaw.com](https://makersclaw.com).
|
|
|
52
53
|
every app. `add <component>` only restores one.
|
|
53
54
|
- **Blocks** — the bigger pieces a surface is assembled from: a board, a
|
|
54
55
|
job panel, and more to come. No app has one until `add` puts it in
|
|
55
|
-
`components/blocks/<name>/`. `search` finds them by what they do.
|
|
56
|
+
`components/blocks/<name>/`. `search` finds them by what they do. A
|
|
57
|
+
block takes its rows as props and hands saves out as callbacks; the
|
|
58
|
+
surface reads and writes. So no block calls the data door (`_claw`,
|
|
59
|
+
`clawUrl`, `claw(`) or imports the app's `lib/`, and one block works
|
|
60
|
+
in a server app, a browser app and a shadcn project alike. `npm test`
|
|
61
|
+
refuses a block that does.
|
|
56
62
|
|
|
57
63
|
Everything this CLI lays is **fixed**: pinned by sha256 in
|
|
58
64
|
`check/pins.json`, and `npm run check` refuses a change. An app composes
|
|
@@ -63,6 +69,8 @@ app's own component in `components/`. Fixes reach apps through `update`.
|
|
|
63
69
|
|
|
64
70
|
```
|
|
65
71
|
npx makersclaw init [dir] [-t next] lay the template: base files, the kit, the check
|
|
72
|
+
[--tables own|public] where its tables live (new apps: own; never changes an app's)
|
|
73
|
+
[--mode browser|server] a browser app or a server app (new apps: server; never changes an app's)
|
|
66
74
|
npx makersclaw add <name...> install blocks, or restore a kit component
|
|
67
75
|
npx makersclaw search <words...> find blocks and components by what they do
|
|
68
76
|
npx makersclaw view <name> one item: props, data, an example
|
|
@@ -70,24 +78,71 @@ npx makersclaw list [--type block] everything in this version
|
|
|
70
78
|
npx makersclaw remove <block...> uninstall blocks no file imports
|
|
71
79
|
npx makersclaw diff what update would change in this app
|
|
72
80
|
npx makersclaw update [--major] lay this version's fixed files and blocks
|
|
81
|
+
npx makersclaw install the app's packages: pnpm, from pnpm-lock.yaml
|
|
82
|
+
npx makersclaw mode <browser|server> move the app to the browser or to a server, and say what its own code must change
|
|
73
83
|
```
|
|
74
84
|
|
|
75
85
|
`--cwd <dir>` names the app (default: the current folder); `--json` prints
|
|
76
86
|
one object instead of text.
|
|
77
87
|
|
|
78
88
|
An app records what it holds in two files: `makersclaw.json` (the template
|
|
79
|
-
and version,
|
|
80
|
-
`check/pins.json` (the hash of every fixed file).
|
|
89
|
+
and version, each block with the version it came from, and where its
|
|
90
|
+
tables live) and `check/pins.json` (the hash of every fixed file). A third
|
|
91
|
+
says which package versions it installs: `pnpm-lock.yaml`, cut by `init`,
|
|
92
|
+
`add` and `update` from the lock this version ships, so every app on one
|
|
93
|
+
version resolves every package the same way. `update` moves an app to
|
|
81
94
|
this version's fixed files and blocks and never touches its own files —
|
|
82
95
|
the shell, the surfaces, `components/*.jsx`. Within `0.x` a new minor is a
|
|
83
96
|
new contract: `update` refuses it until `--major`, because screens written
|
|
84
97
|
against the old one may need rebuilding.
|
|
85
98
|
|
|
99
|
+
`tables` in `makersclaw.json` is `"own"` (the app's own schema; in
|
|
100
|
+
Makersclaw, `app_<name>`) or `"public"` (the schema every app shared
|
|
101
|
+
before 0.7.0; a manifest a lay wrote with no `tables` key is one of
|
|
102
|
+
those). `init` gives a new app `"own"` unless `--tables` says otherwise;
|
|
103
|
+
a new app's manifest may already hold the platform's `brain`, but no lay
|
|
104
|
+
wrote it (it has no `version`). `init` never changes an app's: an app's
|
|
105
|
+
tables move from public to their own schema only with its workspace, so
|
|
106
|
+
a differing `--tables` is refused. Every command keeps the rest of
|
|
107
|
+
`makersclaw.json` (the platform's `brain` declaration, any key it does
|
|
108
|
+
not know), through `init --force` and `update` too.
|
|
109
|
+
|
|
110
|
+
`mode` in `makersclaw.json` says where the app runs. `"server"` (an
|
|
111
|
+
absent key is one) is a server app: a Next server of its own. `"browser"`
|
|
112
|
+
is a browser app: its code runs in the member's browser, its pages are
|
|
113
|
+
files built once that the platform serves, and every read and write
|
|
114
|
+
goes through the platform's data door (people add, edit and delete as
|
|
115
|
+
in any app). Only `"browser"`, exactly, is a browser app. Each mode lays
|
|
116
|
+
its own fixed files: a browser app has its own copy of six
|
|
117
|
+
(`templates/next/browser/`) and leaves out the server's four
|
|
118
|
+
(`proxy.js`, `app/claw-alive/route.js`, `lib/db.js`,
|
|
119
|
+
`lib/brain-server.js`), and the pins follow. `init --mode` sets it on a
|
|
120
|
+
new manifest only; the default stays server, since outside Makersclaw
|
|
121
|
+
no door answers a browser app. `--mode browser` writes `tables: "own"`
|
|
122
|
+
too: the door serves an app's own schema and nothing else. An app
|
|
123
|
+
changes mode only with `makersclaw mode`, which refuses browser unless
|
|
124
|
+
its tables are own, lays that mode's files, removes the other's, and
|
|
125
|
+
prints what the app's own code must change.
|
|
126
|
+
|
|
127
|
+
`install` runs pnpm with `node-linker=hoisted` (npm's flat node_modules,
|
|
128
|
+
every file hard-linked from one store), frozen when the lock names
|
|
129
|
+
exactly package.json's dependencies. It takes pnpm from
|
|
130
|
+
`MAKERSCLAW_PNPM`, then PATH (10.x), then `npm exec pnpm@10.34.6` (npm's
|
|
131
|
+
cached copy when it has one, asked for once), and installs with npm when
|
|
132
|
+
none runs, saying so; not when the registry did not answer, since npm
|
|
133
|
+
would need it too. `MAKERSCLAW_PNPM_STORE` names the store; pnpm's
|
|
134
|
+
default otherwise. Every package's install scripts run, as under npm. An
|
|
135
|
+
app npm installed keeps `package-lock.json`'s versions of the packages it
|
|
136
|
+
added itself. A node_modules copied with hard links (`cp -al`) keeps its
|
|
137
|
+
own records: an install in the copy never writes the original's.
|
|
138
|
+
|
|
86
139
|
## This repository
|
|
87
140
|
|
|
88
141
|
```
|
|
89
142
|
templates/next/template.json which files are fixed, docs, starters or merged; the kit's entries
|
|
90
|
-
templates/next/files/ the app — what `init` lays
|
|
143
|
+
templates/next/files/ the app — what `init` lays (a server app)
|
|
144
|
+
templates/next/browser/ a browser app's own copy of six fixed files (template.json `modes.browser`)
|
|
145
|
+
templates/next/pnpm-lock.yaml the release lock (`npm run lock`): every app's lock is cut from it
|
|
91
146
|
registry/blocks/<name>/ block.json, the files `add` lays, README.md (what view/add print), demo.jsx
|
|
92
147
|
src/ the CLI (no dependencies); src/shadcn.mjs builds the shadcn registry
|
|
93
148
|
r/ built at `npm pack` (never committed): the registry jsDelivr serves
|
|
@@ -97,11 +152,22 @@ test/ npm test · npm run check:regress · npm run test
|
|
|
97
152
|
- `npm test` — the CLI against scratch folders.
|
|
98
153
|
- `npm run check:regress` — the template's `npm run check`, case by case.
|
|
99
154
|
- `npm run test:app` — a real app: init, every block added with its demo as
|
|
100
|
-
a surface, `
|
|
155
|
+
a surface, `makersclaw install` (frozen, from the release lock), the kit
|
|
156
|
+
rendered by React with its warnings on
|
|
101
157
|
(`test/kit-render.test.mjs`, which `npm test` skips), `npm run check`
|
|
102
|
-
(build, start, every surface).
|
|
158
|
+
(build, start, every surface). Then a browser app (`init --mode
|
|
159
|
+
browser`) with every free block's demo as a client surface, checked
|
|
160
|
+
whole with a base path: its export built in a copy, served, every
|
|
161
|
+
surface loaded.
|
|
103
162
|
- `npm run build:registry` — `r/`, the shadcn registry (`--base <url>
|
|
104
163
|
--out <dir>` to serve it locally for a test).
|
|
164
|
+
- `npm run lock` — the release lock, after a dependency of the template or
|
|
165
|
+
a block changes (`-- --fresh` resolves everything again). `prepack`
|
|
166
|
+
checks it.
|
|
167
|
+
- `npm test` runs pnpm for real against a registry it serves itself when
|
|
168
|
+
`MAKERSCLAW_PNPM` or a pnpm 10 on PATH is there, and skips those tests
|
|
169
|
+
otherwise. `MAKERSCLAW_TEST_NETWORK=1` adds the real template from the
|
|
170
|
+
real registry.
|
|
105
171
|
|
|
106
172
|
## License
|
|
107
173
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "makersclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The Makersclaw app template, its kit and its blocks: init an app, add the blocks a surface needs, search the registry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"test:app": "node test/smoke-app.mjs",
|
|
24
24
|
"check:regress": "bash test/check-regress.sh",
|
|
25
25
|
"build:registry": "node scripts/build-registry.mjs",
|
|
26
|
-
"
|
|
26
|
+
"lock": "node scripts/lock.mjs",
|
|
27
|
+
"prepack": "node scripts/lock.mjs --check && node scripts/build-registry.mjs"
|
|
27
28
|
},
|
|
28
29
|
"keywords": [
|
|
29
30
|
"blocks",
|
package/r/annotate.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"title": "Annotate",
|
|
6
6
|
"description": "Mark up a screenshot: drop, pick or paste one, then draw arrows, boxes, highlights, redaction bars and numbered notes on it (Excalidraw underneath, via sketch-canvas). A side list holds the words of each numbered note; a click on a note's number shows its pin. Starter templates (bug report, design review, walkthrough) on a fresh screenshot. The annotation is plain JSON saved through onChange / an async onSave; onSubmit hands over a PNG of the marked-up screenshot with the notes and the sketch.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/sketch-canvas.json"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [],
|
|
12
12
|
"files": [
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"path": "registry/blocks/annotate/README.md",
|
|
33
33
|
"type": "registry:file",
|
|
34
34
|
"target": "@components/makersclaw/blocks/annotate/README.md",
|
|
35
|
-
"content": "# Annotate\n\nMark up a screenshot and say what each mark means. Drop a screenshot on\nit, pick one, or paste one (Cmd/Ctrl+V). It lies locked at the back of the\ncanvas, and on top you draw: numbered notes (pins, <kbd>N</kbd>), arrows,\nboxes, ellipses, freehand, text, a highlight band, and a solid bar to hide\nsomething private. Every numbered pin has a row in the Notes list beside\nthe canvas, where its words are typed; a click on a row's number shows its\npin. A fresh screenshot offers three starters — Bug report, Design review,\nWalkthrough — that lay pins (and a box or arrows) with prompts in their\nrows. A top bar holds the title, the note count, the save state, Replace,\nStart over and the send button.\n\nIt is `sketch-canvas` underneath (Excalidraw, in the workspace theme): its\ntool rail, selection toolbar, zoom, undo and PNG export.\n\n```jsx\nimport { Annotate, annotationToText } from \"../components/blocks/annotate\"; // a Makersclaw app (\"./blocks/annotate\" from components/)\nimport { Annotate, annotationToText } from \"@/components/makersclaw/blocks/annotate\"; // a shadcn project\n```\n\n`Annotate` is a client component: render it from a `\"use client\"`\ncomponent. The helpers are plain functions and work on the server.\n`makersclaw add annotate` adds `sketch-canvas` with it (and Excalidraw:\nrun `npm install` after).\n\n## The data\n\nAn annotation is plain JSON. Store it as it comes out of `onChange` /\n`onSave` (a `jsonb` column).\n\n```js\n{\n type: \"annotation\",\n version: 1,\n image: { src: \"data:image/png;base64,…\", width: 1280, height: 800 }, // or an https URL; null before one is added\n sketch: { type: \"sketch\", version: 1, elements: [ … ], files: {}, appState: {} }, // sketch-canvas's value: the marks\n notes: [\n { n: 1, text: \"The total is wrong here\" },\n { n: 2, text: \"\", hint: \"What did you expect instead?\" }, // hint: a starter's prompt, shown while empty\n ],\n}\n```\n\n- **A pin is the truth for which notes exist.** A pin is an ellipse in the\n sketch whose `customData.note` is its number, with the number as its\n label. `notes` lists one entry per pin on the sketch, by number. Delete\n a pin on the canvas and its entry is gone from the next `onChange`; the\n words stay in memory, so an undo brings both back.\n- **Numbers are stable.** Deleting 2 of 1, 2, 3 leaves 1 and 3, and the\n next pin is 4. A pin duplicated (Cmd+D) or pasted gets the next free\n number.\n- **The screenshot is a data URL** unless you pass `uploadImage`. It is\n scaled down to `maxImageSize` on its long side first (1600 px: about\n 0.5–2 MB as a PNG). To keep images out of the row, upload the file and\n store the URL (see `uploadImage`).\n- Colours in the marks are theme tokens (see sketch-canvas's README): a\n saved annotation repaints with the theme.\n\n## Props\n\n- `value` — the annotation, controlled. A new `value` that is not what the\n block last sent loads it (another record). Pass back exactly what\n `onChange` gave you and nothing resets.\n- `defaultValue` — the starting annotation, uncontrolled. `{ image }`\n alone opens a screenshot with nothing on it. With neither, the block\n starts at the drop zone.\n- `onChange(annotation)` — the whole annotation after an edit settles: a\n mark (250 ms after the canvas goes quiet), a note's words (500 ms after\n typing stops), a new screenshot (at once). Not called for pan, zoom or\n selection.\n- `onSave(annotation)` — `async`, returns `{ ok: true }` or\n `{ ok: false, error? }`. Called right after `onChange`. The top bar\n shows \"Saving…\", \"Saved\", or the error with Retry. A throw counts as a\n failure.\n- `onSubmit({ image, notes, sketch, screenshot, text })` — `async`,\n `{ ok }`. Without it there is no send button. `image` is a PNG `Blob`\n of the screenshot with the marks (2× scale); `notes` is\n `[{ n, text, hint?, x, y }]` (x, y: the pin's centre in screenshot\n pixels); `sketch` the marks; `screenshot` the `{ src, width, height }`\n underneath; `text` is `annotationToText` of it all. The bar shows\n \"Sent\" or the error.\n- `submitLabel` — the send button's words (default \"Send\"): \"File the\n bug\", \"Send to support\", \"Ask the agent\".\n- `uploadImage(file)` — `async`, returns `{ ok: true, src }` or\n `{ ok: false, error? }`. With it, a dropped or picked screenshot is\n uploaded as it is and `image.src` is the URL you return (same origin,\n or `exportImage` cannot read it back). Without it, the file becomes a\n data URL, scaled to `maxImageSize`.\n- `maxImageSize` — px on the long side a screenshot is scaled down to when\n it becomes a data URL (default 1600; `0` keeps it as it is).\n- `title` — the top bar's heading and the region's name (default\n \"Annotate a screenshot\").\n- `readOnly` — `false` (default). `true`: the marks to look at (pan and\n zoom), the notes as text; no rail, no drop, no send.\n- `tone` — the pins' colour, a token: `destructive` (default), `primary`,\n `warning`, `success`, `info`, `chart-2` … `chart-5`.\n- `tools` — the canvas tools in the rail (sketch-canvas ids). Default\n `ANNOTATE_TOOLS`: select, pan, arrow, rectangle, ellipse, draw, text,\n eraser. The three annotate stamps (numbered note, highlight, hide) always\n follow them.\n- `canvasRef` — a ref (`useSketchCanvas()` from sketch-canvas) that\n receives the canvas's handle: `exportImage()`, `getScene()`,\n `zoomToFit()`…\n- `label` — the region's accessible name (default: `title`).\n- `className` — layout: width, margin, height. From 768 px the default\n height is `h-[36rem]` (canvas and notes side by side); a height class of\n yours replaces it. Below that the notes stack under a 26rem canvas.\n Never colour.\n\n### Keys\n\nOn the canvas: <kbd>N</kbd> a numbered note (click to place it; Enter\ndrops it in the middle), <kbd>A</kbd> arrow, <kbd>R</kbd> rectangle,\n<kbd>O</kbd> ellipse, <kbd>P</kbd> draw, <kbd>T</kbd> text, <kbd>E</kbd>\neraser, <kbd>V</kbd> select, <kbd>H</kbd> pan, Delete, Cmd/Ctrl+D, Cmd/Ctrl+Z,\nShift+1 fit. \"Add note\" in the Notes list drops a pin in the middle of the\nview without the mouse.\n\n### Helpers\n\nPlain functions; they take an annotation (or a sketch) and never throw on\na partial one:\n\n- `emptyAnnotation()`, `toAnnotation(value)` — the shape above.\n- `annotationNotes(sketch, notes)` — `[{ n, text, hint?, x, y }]`: one per\n pin, by number.\n- `annotationToText(annotation)` — the words, for an agent or a reader:\n `\"1. The total is wrong here\\n2. …\"`, then any free text drawn on it\n under \"Also written on it:\".\n- `noteMarkers(sketch)` — the pins: `[{ n, id, x, y, width, height }]`.\n- `nextNoteNumber(sketch)` — the number the next pin gets.\n- `markerSkeleton(n, { size, tone })` — a pin as sketch-canvas skeletons,\n for `canvasRef.current.insertElements` (a pin you place from code).\n- `ANNOTATE_TEMPLATES`, `ANNOTATE_TOOLS` — the starters and the default\n tools.\n\n## Example\n\nA feedback widget that saves itself and files a ticket:\n\n```jsx\n\"use client\";\nimport {
|
|
35
|
+
"content": "# Annotate\n\nMark up a screenshot and say what each mark means. Drop a screenshot on\nit, pick one, or paste one (Cmd/Ctrl+V). It lies locked at the back of the\ncanvas, and on top you draw: numbered notes (pins, <kbd>N</kbd>), arrows,\nboxes, ellipses, freehand, text, a highlight band, and a solid bar to hide\nsomething private. Every numbered pin has a row in the Notes list beside\nthe canvas, where its words are typed; a click on a row's number shows its\npin. A fresh screenshot offers three starters — Bug report, Design review,\nWalkthrough — that lay pins (and a box or arrows) with prompts in their\nrows. A top bar holds the title, the note count, the save state, Replace,\nStart over and the send button.\n\nIt is `sketch-canvas` underneath (Excalidraw, in the workspace theme): its\ntool rail, selection toolbar, zoom, undo and PNG export.\n\n```jsx\nimport { Annotate, annotationToText } from \"../components/blocks/annotate\"; // a Makersclaw app (\"./blocks/annotate\" from components/)\nimport { Annotate, annotationToText } from \"@/components/makersclaw/blocks/annotate\"; // a shadcn project\n```\n\n`Annotate` is a client component: render it from a `\"use client\"`\ncomponent. The helpers are plain functions and work on the server.\n`makersclaw add annotate` adds `sketch-canvas` with it (and Excalidraw:\nrun `npm install` after).\n\n## The data\n\nAn annotation is plain JSON. Store it as it comes out of `onChange` /\n`onSave` (a `jsonb` column).\n\n```js\n{\n type: \"annotation\",\n version: 1,\n image: { src: \"data:image/png;base64,…\", width: 1280, height: 800 }, // or an https URL; null before one is added\n sketch: { type: \"sketch\", version: 1, elements: [ … ], files: {}, appState: {} }, // sketch-canvas's value: the marks\n notes: [\n { n: 1, text: \"The total is wrong here\" },\n { n: 2, text: \"\", hint: \"What did you expect instead?\" }, // hint: a starter's prompt, shown while empty\n ],\n}\n```\n\n- **A pin is the truth for which notes exist.** A pin is an ellipse in the\n sketch whose `customData.note` is its number, with the number as its\n label. `notes` lists one entry per pin on the sketch, by number. Delete\n a pin on the canvas and its entry is gone from the next `onChange`; the\n words stay in memory, so an undo brings both back.\n- **Numbers are stable.** Deleting 2 of 1, 2, 3 leaves 1 and 3, and the\n next pin is 4. A pin duplicated (Cmd+D) or pasted gets the next free\n number.\n- **The screenshot is a data URL** unless you pass `uploadImage`. It is\n scaled down to `maxImageSize` on its long side first (1600 px: about\n 0.5–2 MB as a PNG). To keep images out of the row, upload the file and\n store the URL (see `uploadImage`).\n- Colours in the marks are theme tokens (see sketch-canvas's README): a\n saved annotation repaints with the theme.\n\n## Props\n\n- `value` — the annotation, controlled. A new `value` that is not what the\n block last sent loads it (another record). Pass back exactly what\n `onChange` gave you and nothing resets.\n- `defaultValue` — the starting annotation, uncontrolled. `{ image }`\n alone opens a screenshot with nothing on it. With neither, the block\n starts at the drop zone.\n- `onChange(annotation)` — the whole annotation after an edit settles: a\n mark (250 ms after the canvas goes quiet), a note's words (500 ms after\n typing stops), a new screenshot (at once). Not called for pan, zoom or\n selection.\n- `onSave(annotation)` — `async`, returns `{ ok: true }` or\n `{ ok: false, error? }`. Called right after `onChange`. The top bar\n shows \"Saving…\", \"Saved\", or the error with Retry. A throw counts as a\n failure.\n- `onSubmit({ image, notes, sketch, screenshot, text })` — `async`,\n `{ ok }`. Without it there is no send button. `image` is a PNG `Blob`\n of the screenshot with the marks (2× scale); `notes` is\n `[{ n, text, hint?, x, y }]` (x, y: the pin's centre in screenshot\n pixels); `sketch` the marks; `screenshot` the `{ src, width, height }`\n underneath; `text` is `annotationToText` of it all. The bar shows\n \"Sent\" or the error.\n- `submitLabel` — the send button's words (default \"Send\"): \"File the\n bug\", \"Send to support\", \"Ask the agent\".\n- `uploadImage(file)` — `async`, returns `{ ok: true, src }` or\n `{ ok: false, error? }`. With it, a dropped or picked screenshot is\n uploaded as it is and `image.src` is the URL you return (same origin,\n or `exportImage` cannot read it back). Without it, the file becomes a\n data URL, scaled to `maxImageSize`.\n- `maxImageSize` — px on the long side a screenshot is scaled down to when\n it becomes a data URL (default 1600; `0` keeps it as it is).\n- `title` — the top bar's heading and the region's name (default\n \"Annotate a screenshot\").\n- `readOnly` — `false` (default). `true`: the marks to look at (pan and\n zoom), the notes as text; no rail, no drop, no send.\n- `tone` — the pins' colour, a token: `destructive` (default), `primary`,\n `warning`, `success`, `info`, `chart-2` … `chart-5`.\n- `tools` — the canvas tools in the rail (sketch-canvas ids). Default\n `ANNOTATE_TOOLS`: select, pan, arrow, rectangle, ellipse, draw, text,\n eraser. The three annotate stamps (numbered note, highlight, hide) always\n follow them.\n- `canvasRef` — a ref (`useSketchCanvas()` from sketch-canvas) that\n receives the canvas's handle: `exportImage()`, `getScene()`,\n `zoomToFit()`…\n- `label` — the region's accessible name (default: `title`).\n- `className` — layout: width, margin, height. From 768 px the default\n height is `h-[36rem]` (canvas and notes side by side); a height class of\n yours replaces it. Below that the notes stack under a 26rem canvas.\n Never colour.\n\n### Keys\n\nOn the canvas: <kbd>N</kbd> a numbered note (click to place it; Enter\ndrops it in the middle), <kbd>A</kbd> arrow, <kbd>R</kbd> rectangle,\n<kbd>O</kbd> ellipse, <kbd>P</kbd> draw, <kbd>T</kbd> text, <kbd>E</kbd>\neraser, <kbd>V</kbd> select, <kbd>H</kbd> pan, Delete, Cmd/Ctrl+D, Cmd/Ctrl+Z,\nShift+1 fit. \"Add note\" in the Notes list drops a pin in the middle of the\nview without the mouse.\n\n### Helpers\n\nPlain functions; they take an annotation (or a sketch) and never throw on\na partial one:\n\n- `emptyAnnotation()`, `toAnnotation(value)` — the shape above.\n- `annotationNotes(sketch, notes)` — `[{ n, text, hint?, x, y }]`: one per\n pin, by number.\n- `annotationToText(annotation)` — the words, for an agent or a reader:\n `\"1. The total is wrong here\\n2. …\"`, then any free text drawn on it\n under \"Also written on it:\".\n- `noteMarkers(sketch)` — the pins: `[{ n, id, x, y, width, height }]`.\n- `nextNoteNumber(sketch)` — the number the next pin gets.\n- `markerSkeleton(n, { size, tone })` — a pin as sketch-canvas skeletons,\n for `canvasRef.current.insertElements` (a pin you place from code).\n- `ANNOTATE_TEMPLATES`, `ANNOTATE_TOOLS` — the starters and the default\n tools.\n\n## Example\n\nA feedback widget that saves itself and files a ticket:\n\n```jsx\n// surfaces/feedback.jsx — client: one ticket, annotated\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { insertRow, updateRow } from \"../lib/actions\";\nimport { Annotate } from \"../components/blocks/annotate\";\n\nexport default function Feedback() {\n const id = useSearchParams().get(\"row\");\n const { row: ticket, loading } = useRow({ source: \"tickets\", id });\n if (loading || !ticket) return null;\n return (\n <Annotate\n key={ticket.id}\n title=\"What should change?\"\n defaultValue={ticket.annotation} // jsonb, may be null; read once per ticket\n onSave={(annotation) => updateRow({ source: \"tickets\", id: ticket.id, patch: { annotation } })}\n onSubmit={({ notes, text }) => // image: a PNG Blob, to upload if you keep it\n insertRow({ source: \"bug_reports\", values: { ticket_id: ticket.id, summary: text, note_count: notes.length } })\n }\n submitLabel=\"File the bug\"\n className=\"h-dvh\"\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Crop, rotate or resize the screenshot. Replace swaps it for another;\n the marks stay where they were.\n- Annotate several screenshots at once: one image per annotation (keep a\n list of annotations for a series).\n- Renumber pins after a delete — numbers stay what they were.\n- Blur: \"hide\" is a solid bar drawn over the image. The original pixels\n are still in `image.src`; send the exported PNG, not the source, when\n what is hidden must not leave.\n- Take a screenshot for you: the browser cannot capture the page without\n asking; the person drops or pastes one.\n- Collaborate live: saves are last-write-wins.\n\nA markup tool it cannot be is your own component in `components/` —\nnot an edit to this block. Its files are fixed: `npm run check` refuses a\nchange, and `makersclaw add annotate` restores them.\n"
|
|
36
36
|
}
|
|
37
37
|
],
|
|
38
38
|
"categories": [
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"docs": "Installed to components/makersclaw/blocks/annotate. Import: import { Annotate, ANNOTATE_TOOLS, ANNOTATE_TEMPLATES, emptyAnnotation, toAnnotation, annotationNotes, annotationToText, noteMarkers, nextNoteNumber, markerSkeleton } from \"@/components/makersclaw/blocks/annotate\". Props and an example: components/makersclaw/blocks/annotate/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
56
56
|
"meta": {
|
|
57
57
|
"makersclaw": {
|
|
58
|
-
"version": "0.
|
|
58
|
+
"version": "0.7.0",
|
|
59
59
|
"useWhen": "Someone needs to point at parts of a screen and say something about each: a bug report, design feedback, a support question, a walkthrough, a request to an agent about what to change. Not a blank drawing board (whiteboard or sketch-canvas), not a wireframe (sketch-to-app), not an image upload alone (file-upload)."
|
|
60
60
|
}
|
|
61
61
|
}
|
package/r/approval-request.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "ApprovalRequest",
|
|
6
6
|
"description": "One request waiting on a decision: what is asked for as label/value facts (an amount in large type), who asked and when, why, a status badge, and Approve / Reject (reject asks for a reason). The decision goes out through async onApprove / onReject; the block shows it working, a failure, then the outcome in place of the buttons. A card alone, or a page of detail sections beside the card.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"path": "registry/blocks/approval-request/README.md",
|
|
26
26
|
"type": "registry:file",
|
|
27
27
|
"target": "@components/makersclaw/blocks/approval-request/README.md",
|
|
28
|
-
"content": "# ApprovalRequest\n\nOne request waiting on a decision: what is asked for (label and value\nfacts, the amount in large type), who asked and when, why, and Approve /\nReject. Reject asks for a reason the requester will see. The decision\ngoes out through your callbacks; while one runs its button spins, when it\nfails the card says why and the buttons stay, and once it lands the card\nshows the outcome (\"Approved by you · 23 September 2026\") in their place.\n\n```jsx\nimport { ApprovalRequest } from \"../components/blocks/approval-request\"; // a Makersclaw app (\"./blocks/approval-request\" from components/)\nimport { ApprovalRequest } from \"@/components/makersclaw/blocks/approval-request\"; // a shadcn project\n```\n\nA client component. Load the request in the server surface (README §4)\nand pass its fields in.\n\n## The data\n\n- `details` — the card's facts, in order:\n `[{ label, value, icon?, emphasis? }]`. `value` is a string or a node;\n `icon` is a kit `Icon` name; `emphasis` sets the value large (the\n amount).\n- `sections` — `variant=\"page\"` only, the detail beside the card:\n `[{ title, fields: [{ label, value, emphasis? }] }]`.\n- `requester` — `{ name, role?, src? }` (`src` a picture URL).\n\n## Props\n\n- `title` — what is asked for (\"Linear Business\", \"Refund for order\n 20931\"). Without it (and not `loading`) the block shows `emptyLabel`.\n- `onApprove()` — save the yes. Return `{ ok: true }` or\n `{ ok: false, error }`; a throw counts as failed and its message shows.\n- `onReject(reason)` — save the no, with the reason typed (a trimmed\n string, \"\" when `reasonRequired` is off). Same return as `onApprove`.\n Leave either out and its button is not drawn.\n- `variant` — `\"card\"` (default: the card alone, with the requester, the\n date and the reason why) or `\"page\"`: a heading, the `sections` and \"The\n request\" (requester, date, why) on the left, the card on the right\n (under them on a phone), sticky from 1024px.\n- `status` — `\"pending\"` (default) · `\"approved\"` · `\"rejected\"`. The\n block shows its own outcome as soon as a decision saves; a new\n `status` from you replaces it.\n- `decidedBy` · `decidedAt` · `decisionNote` — for a request decided\n before: who, when, and the reason, shown under the outcome.\n- `kind` — a small badge beside the title (\"Per seat\", \"Refund\").\n- `requestedAt` — an ISO time, a `Date`, or a day (`\"2026-09-28\"`, a\n `date` column), which reads as that day wherever the viewer is.\n- `justification` — the requester's reason, as a paragraph.\n- `reasonRequired` — default `true`: Reject opens a reason field and\n sends only when it is filled. `false`: Reject sends at once.\n- `approveLabel` · `rejectLabel` — default \"Approve\" / \"Reject\"\n (\"Refund €149.00\", \"Decline\").\n- `heading` · `description` — `page`: the page's title (default `title`)\n and the line under it.\n- `formatDate(value)` — how a date reads; default the viewer's long date\n (\"21 September 2026\").\n- `loading` — a skeleton card while the request loads.\n- `emptyLabel` — default \"Nothing is waiting for a decision.\".\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n//
|
|
28
|
+
"content": "# ApprovalRequest\n\nOne request waiting on a decision: what is asked for (label and value\nfacts, the amount in large type), who asked and when, why, and Approve /\nReject. Reject asks for a reason the requester will see. The decision\ngoes out through your callbacks; while one runs its button spins, when it\nfails the card says why and the buttons stay, and once it lands the card\nshows the outcome (\"Approved by you · 23 September 2026\") in their place.\n\n```jsx\nimport { ApprovalRequest } from \"../components/blocks/approval-request\"; // a Makersclaw app (\"./blocks/approval-request\" from components/)\nimport { ApprovalRequest } from \"@/components/makersclaw/blocks/approval-request\"; // a shadcn project\n```\n\nA client component. Load the request in the server surface (README §4)\nand pass its fields in.\n\n## The data\n\n- `details` — the card's facts, in order:\n `[{ label, value, icon?, emphasis? }]`. `value` is a string or a node;\n `icon` is a kit `Icon` name; `emphasis` sets the value large (the\n amount).\n- `sections` — `variant=\"page\"` only, the detail beside the card:\n `[{ title, fields: [{ label, value, emphasis? }] }]`.\n- `requester` — `{ name, role?, src? }` (`src` a picture URL).\n\n## Props\n\n- `title` — what is asked for (\"Linear Business\", \"Refund for order\n 20931\"). Without it (and not `loading`) the block shows `emptyLabel`.\n- `onApprove()` — save the yes. Return `{ ok: true }` or\n `{ ok: false, error }`; a throw counts as failed and its message shows.\n- `onReject(reason)` — save the no, with the reason typed (a trimmed\n string, \"\" when `reasonRequired` is off). Same return as `onApprove`.\n Leave either out and its button is not drawn.\n- `variant` — `\"card\"` (default: the card alone, with the requester, the\n date and the reason why) or `\"page\"`: a heading, the `sections` and \"The\n request\" (requester, date, why) on the left, the card on the right\n (under them on a phone), sticky from 1024px.\n- `status` — `\"pending\"` (default) · `\"approved\"` · `\"rejected\"`. The\n block shows its own outcome as soon as a decision saves; a new\n `status` from you replaces it.\n- `decidedBy` · `decidedAt` · `decisionNote` — for a request decided\n before: who, when, and the reason, shown under the outcome.\n- `kind` — a small badge beside the title (\"Per seat\", \"Refund\").\n- `requestedAt` — an ISO time, a `Date`, or a day (`\"2026-09-28\"`, a\n `date` column), which reads as that day wherever the viewer is.\n- `justification` — the requester's reason, as a paragraph.\n- `reasonRequired` — default `true`: Reject opens a reason field and\n sends only when it is filled. `false`: Reject sends at once.\n- `approveLabel` · `rejectLabel` — default \"Approve\" / \"Reject\"\n (\"Refund €149.00\", \"Decline\").\n- `heading` · `description` — `page`: the page's title (default `title`)\n and the line under it.\n- `formatDate(value)` — how a date reads; default the viewer's long date\n (\"21 September 2026\").\n- `loading` — a skeleton card while the request loads.\n- `emptyLabel` — default \"Nothing is waiting for a decision.\".\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// surfaces/approval.jsx — client: one request, read and decided\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { ApprovalRequest } from \"../components/blocks/approval-request\";\n\nconst usd = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\" });\n\nexport default function PurchaseApproval() {\n const id = useSearchParams().get(\"row\"); // /s/approval?row=<id>\n const { row: request, loading } = useRow({ source: \"purchase_requests\", id });\n if (loading || !request) return null;\n // decided_by and decided_at: a before-update trigger sets them from the member, never the page (the app's README §5)\n const decide = (status, reason) =>\n updateRow({ source: \"purchase_requests\", id: request.id, patch: { status, reason: reason ?? null }, detail: request.product });\n return (\n <ApprovalRequest\n title={request.product}\n kind={request.pricing}\n details={[\n { label: \"Starts\", value: request.starts_on, icon: \"calendar\" },\n { label: \"Seats\", value: `${request.seats} people`, icon: \"users\" },\n { label: \"Yearly cost\", value: usd.format(request.amount), icon: \"dollar\", emphasis: true },\n ]}\n requester={{ name: request.requested_by, role: request.team }}\n requestedAt={request.created_at}\n justification={request.why}\n status={request.status}\n decidedBy={request.decided_by}\n decidedAt={request.decided_at}\n decisionNote={request.reason}\n onApprove={() => decide(\"approved\")}\n onReject={(reason) => decide(\"rejected\", reason)}\n />\n );\n}\n```\n\nA decision that answers ok reads the request again, so the card shows\nit decided.\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- List many requests: put them in a `data-table` and open one in this\n block (a `Sheet`), or render one per row.\n- Take a decision in several steps (two approvers, a delegate): `status`\n is one of three.\n- Edit the request: its facts are read-only here.\n\nA request it cannot be is your own component in `components/`, built\nfrom the kit — not an edit to this block. Its files are fixed: `npm run\ncheck` refuses a change, and `makersclaw add approval-request` restores\nthem.\n\n## Credits\n\nPorted from Creative Tim UI (https://github.com/creativetimofficial/ui, software-purchase-01 and software-purchase-card, MIT), rewritten on the Makersclaw kit.\n"
|
|
29
29
|
}
|
|
30
30
|
],
|
|
31
31
|
"categories": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"docs": "Installed to components/makersclaw/blocks/approval-request. Import: import { ApprovalRequest } from \"@/components/makersclaw/blocks/approval-request\". Props and an example: components/makersclaw/blocks/approval-request/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
46
46
|
"meta": {
|
|
47
47
|
"makersclaw": {
|
|
48
|
-
"version": "0.
|
|
48
|
+
"version": "0.7.0",
|
|
49
49
|
"useWhen": "Someone must say yes or no to one thing another person asked for: a purchase, a refund, time off, a discount, an access request, a published post. Not for a queue of many requests (a data-table with row actions) or a yes/no about the founder's own action (kit Confirm)."
|
|
50
50
|
}
|
|
51
51
|
}
|
package/r/audio-player.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "AudioPlayer",
|
|
6
6
|
"description": "A waveform audio player (wavesurfer.js) in the workspace theme: play, skip back and forward, speed, mute; regions (spans of the audio in a theme tone, labelled in a lane under the waveform, click to play one) and markers (pins at moments, click to jump); a hover time readout; drag across the waveform to mark a new region; add a marker at the playhead; keyboard like a video player (K, J, L, M, arrows). Time events and a handle (seek, play a range) for syncing a transcript.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [
|
|
11
11
|
"wavesurfer.js@8.0.0",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"path": "registry/blocks/audio-player/README.md",
|
|
47
47
|
"type": "registry:file",
|
|
48
48
|
"target": "@components/makersclaw/blocks/audio-player/README.md",
|
|
49
|
-
"content": "# AudioPlayer\n\nA waveform audio player in the workspace theme. Play, skip back and\nforward 15 seconds, change speed, mute. Hover the waveform to read the\ntime; click it to jump; drag across it to mark a region. **Regions** are\nspans of the audio in a theme tone — a topic, a speaker turn, a clip —\ntinted on the waveform and labelled in a lane under it; click a label to\nplay just that span. **Markers** are moments — a comment, a flag — shown\nas pins above the waveform; click one to jump there. \"Marker\" adds one at\nthe playhead. It loads with a skeleton waveform, says so plainly when a\nfile will not play, and shows a friendly empty state when there is no\naudio yet.\n\nIt is [wavesurfer.js](https://wavesurfer.xyz) (BSD-3-Clause) underneath:\nit decodes, plays and paints the waveform. Regions, markers, the hover\nline, the controls and every state are ours, in the kit. The blocks built\non this one — `call-review` — import it, never `wavesurfer.js`.\n\n```jsx\nimport { AudioPlayer, useAudioPlayer } from \"../components/blocks/audio-player\"; // a Makersclaw app (\"./blocks/audio-player\" from components/)\nimport { AudioPlayer, useAudioPlayer } from \"@/components/makersclaw/blocks/audio-player\"; // a shadcn project\n```\n\n`AudioPlayer` is a client component: render it from a `\"use client\"`\ncomponent. `formatTime`, `formatRange`, `spokenTime` and `regionAt` are\nplain functions and work on the server.\n\n`makersclaw add audio-player` adds `wavesurfer.js` and\n`@wavesurfer/react` (pinned, exact) to package.json: run `npm install`\nafter. Both load with the page (no `import()`); the player itself starts\nin the browser, and until then the frame shows a skeleton waveform.\n\n## The data\n\nTimes are **seconds** from the start of the audio (numbers, fractions\nallowed).\n\n```js\n// a region: a span of the audio\n{ id: \"pricing\", start: 12.4, end: 25.6, label: \"Pricing and SSO\", tone: \"chart-3\" }\n\n// a marker: a moment\n{ id: \"m1\", time: 17.2, label: \"Asked whether SSO is on every plan\", tone: \"warning\" }\n```\n\n- `id` — unique within its list; it comes back to you in the callbacks.\n- `label` — shown in the lane (a region) or the tooltip (both), and read\n to a screen reader. Keep it short.\n- `tone` — a theme token, never a colour: `primary` (the default),\n `chart-2` … `chart-5`, `success`, `warning`, `info`, `destructive`,\n `muted`. `TONES` lists them. Use the `chart-*` tones for kinds (topics,\n speakers) and the status tones for meaning (a risk, a win).\n- Any other keys ride along untouched (a comment's author, a transcript\n line's id…) and come back in `onRegionClick`, `onMarkerClick` and\n `onActiveRegionChange`.\n- Regions may overlap; where they do, the one that started last is the\n active one. Anything past the end of the audio is clipped to it.\n\n## Props\n\n- `src` — the audio: a URL (`https://…`, `/api/recordings/123`, a\n `data:` URL) or a `Blob` / `File` (from an upload, a recording). A Blob\n plays through an object URL the player makes and revokes. Nothing\n (`null`) shows the empty state.\n- `peaks` — optional precomputed waveform (`[Float32Array | number[]]`,\n one per channel) with `duration`: the waveform draws at once without\n decoding the file — use it for long calls. Keep the array's identity\n stable (state or `useMemo`); a new array rebuilds the player.\n- `duration` — seconds, with `peaks`. Otherwise it is read from the file.\n- `title`, `description` — the heading over the waveform and the muted\n line under it (\"Discovery call · Northwind\", \"Maya and Leo · Sep 18\").\n- `regions` — `[{ id, start, end, label?, tone? }]` (default none).\n- `markers` — `[{ id, time, label?, tone? }]` (default none).\n- `onAddMarker({ time })` — shows the \"Marker\" button (and the M key); the\n new pin appears at once, dimmed, while you save. Add the marker to\n `markers` and return `{ ok: true }`; return `{ ok: false, error }` (or\n throw) and the pin goes, with a notice that says why. Without it there\n is no button.\n- `onRegionCreate({ start, end })` — makes a drag across the waveform\n mark a region (a crosshair cursor, the span and its times shown while\n dragging; shorter than half a second counts as a click). Same promise\n as `onAddMarker`: add it to `regions`, return `{ ok }`. Without it a\n drag scrubs.\n- `onMarkerClick(marker)` — after the jump to it.\n- `onRegionClick(region)` — after it starts playing (it stops at its end).\n- `onActiveRegionChange(region | null)` — once each time the region under\n the playhead changes (a transcript highlighting the line being said).\n- `onTimeUpdate(seconds)` — continuously while playing (every frame):\n keep it cheap.\n- `onSeek(seconds)` — every jump: a click, a key, a skip, a pin, the handle.\n- `onReady({ duration })`, `onPlay()`, `onPause()`, `onFinish()`,\n `onError(error)` — as named.\n- `rates` — the speeds offered, default `[0.75, 1, 1.25, 1.5, 2]`.\n- `defaultRate` — the starting speed, default `1`.\n- `skipSeconds` — the skip buttons' step (and J / L, Page Up / Down),\n default `15`.\n- `emptyTitle`, `emptyBody`, `emptyAction` — the empty state's words and\n one action (a node: an upload button). Defaults: \"No recording yet\",\n \"When there is audio to play, it shows up here.\", none.\n- `bare` — no card around it (no border, background or padding), for a\n player inside your own card or row. Default `false`.\n- `playerRef` — a ref from `useAudioPlayer()` for the handle below.\n- `className` — on the outer element.\n\n### The handle\n\n```jsx\nconst player = useAudioPlayer();\n<AudioPlayer src={url} playerRef={player} />;\nplayer.current?.seek(84);\n```\n\n`play()`, `pause()`, `toggle()`, `seek(seconds)`, `skip(±seconds)`,\n`playRange(start, end?)` (stops at `end`), `playRegion(id)`,\n`setRate(n)`, `setMuted(bool)`, `addMarker()` (as the button),\n`getTime()`, `getDuration()`, `isPlaying()`, `isReady()`. Calls before\nthe audio is ready do nothing.\n\n### Keys\n\nOn the waveform (a slider, one Tab stop): ← / → 5 seconds, Page Down /\nPage Up `skipSeconds`, Home / End, Space or Enter to play or pause.\nAnywhere in the player (not while typing, not with ⌘ / Ctrl / Alt): K\nplay / pause, J / L back / forward `skipSeconds`, M add a marker. Pins\nand region labels are buttons in Tab order.\n\n## Example\n\nA call recording with its topics and comments,
|
|
49
|
+
"content": "# AudioPlayer\n\nA waveform audio player in the workspace theme. Play, skip back and\nforward 15 seconds, change speed, mute. Hover the waveform to read the\ntime; click it to jump; drag across it to mark a region. **Regions** are\nspans of the audio in a theme tone — a topic, a speaker turn, a clip —\ntinted on the waveform and labelled in a lane under it; click a label to\nplay just that span. **Markers** are moments — a comment, a flag — shown\nas pins above the waveform; click one to jump there. \"Marker\" adds one at\nthe playhead. It loads with a skeleton waveform, says so plainly when a\nfile will not play, and shows a friendly empty state when there is no\naudio yet.\n\nIt is [wavesurfer.js](https://wavesurfer.xyz) (BSD-3-Clause) underneath:\nit decodes, plays and paints the waveform. Regions, markers, the hover\nline, the controls and every state are ours, in the kit. The blocks built\non this one — `call-review` — import it, never `wavesurfer.js`.\n\n```jsx\nimport { AudioPlayer, useAudioPlayer } from \"../components/blocks/audio-player\"; // a Makersclaw app (\"./blocks/audio-player\" from components/)\nimport { AudioPlayer, useAudioPlayer } from \"@/components/makersclaw/blocks/audio-player\"; // a shadcn project\n```\n\n`AudioPlayer` is a client component: render it from a `\"use client\"`\ncomponent. `formatTime`, `formatRange`, `spokenTime` and `regionAt` are\nplain functions and work on the server.\n\n`makersclaw add audio-player` adds `wavesurfer.js` and\n`@wavesurfer/react` (pinned, exact) to package.json: run `npm install`\nafter. Both load with the page (no `import()`); the player itself starts\nin the browser, and until then the frame shows a skeleton waveform.\n\n## The data\n\nTimes are **seconds** from the start of the audio (numbers, fractions\nallowed).\n\n```js\n// a region: a span of the audio\n{ id: \"pricing\", start: 12.4, end: 25.6, label: \"Pricing and SSO\", tone: \"chart-3\" }\n\n// a marker: a moment\n{ id: \"m1\", time: 17.2, label: \"Asked whether SSO is on every plan\", tone: \"warning\" }\n```\n\n- `id` — unique within its list; it comes back to you in the callbacks.\n- `label` — shown in the lane (a region) or the tooltip (both), and read\n to a screen reader. Keep it short.\n- `tone` — a theme token, never a colour: `primary` (the default),\n `chart-2` … `chart-5`, `success`, `warning`, `info`, `destructive`,\n `muted`. `TONES` lists them. Use the `chart-*` tones for kinds (topics,\n speakers) and the status tones for meaning (a risk, a win).\n- Any other keys ride along untouched (a comment's author, a transcript\n line's id…) and come back in `onRegionClick`, `onMarkerClick` and\n `onActiveRegionChange`.\n- Regions may overlap; where they do, the one that started last is the\n active one. Anything past the end of the audio is clipped to it.\n\n## Props\n\n- `src` — the audio: a URL (`https://…`, `/api/recordings/123`, a\n `data:` URL) or a `Blob` / `File` (from an upload, a recording). A Blob\n plays through an object URL the player makes and revokes. Nothing\n (`null`) shows the empty state.\n- `peaks` — optional precomputed waveform (`[Float32Array | number[]]`,\n one per channel) with `duration`: the waveform draws at once without\n decoding the file — use it for long calls. Keep the array's identity\n stable (state or `useMemo`); a new array rebuilds the player.\n- `duration` — seconds, with `peaks`. Otherwise it is read from the file.\n- `title`, `description` — the heading over the waveform and the muted\n line under it (\"Discovery call · Northwind\", \"Maya and Leo · Sep 18\").\n- `regions` — `[{ id, start, end, label?, tone? }]` (default none).\n- `markers` — `[{ id, time, label?, tone? }]` (default none).\n- `onAddMarker({ time })` — shows the \"Marker\" button (and the M key); the\n new pin appears at once, dimmed, while you save. Add the marker to\n `markers` and return `{ ok: true }`; return `{ ok: false, error }` (or\n throw) and the pin goes, with a notice that says why. Without it there\n is no button.\n- `onRegionCreate({ start, end })` — makes a drag across the waveform\n mark a region (a crosshair cursor, the span and its times shown while\n dragging; shorter than half a second counts as a click). Same promise\n as `onAddMarker`: add it to `regions`, return `{ ok }`. Without it a\n drag scrubs.\n- `onMarkerClick(marker)` — after the jump to it.\n- `onRegionClick(region)` — after it starts playing (it stops at its end).\n- `onActiveRegionChange(region | null)` — once each time the region under\n the playhead changes (a transcript highlighting the line being said).\n- `onTimeUpdate(seconds)` — continuously while playing (every frame):\n keep it cheap.\n- `onSeek(seconds)` — every jump: a click, a key, a skip, a pin, the handle.\n- `onReady({ duration })`, `onPlay()`, `onPause()`, `onFinish()`,\n `onError(error)` — as named.\n- `rates` — the speeds offered, default `[0.75, 1, 1.25, 1.5, 2]`.\n- `defaultRate` — the starting speed, default `1`.\n- `skipSeconds` — the skip buttons' step (and J / L, Page Up / Down),\n default `15`.\n- `emptyTitle`, `emptyBody`, `emptyAction` — the empty state's words and\n one action (a node: an upload button). Defaults: \"No recording yet\",\n \"When there is audio to play, it shows up here.\", none.\n- `bare` — no card around it (no border, background or padding), for a\n player inside your own card or row. Default `false`.\n- `playerRef` — a ref from `useAudioPlayer()` for the handle below.\n- `className` — on the outer element.\n\n### The handle\n\n```jsx\nconst player = useAudioPlayer();\n<AudioPlayer src={url} playerRef={player} />;\nplayer.current?.seek(84);\n```\n\n`play()`, `pause()`, `toggle()`, `seek(seconds)`, `skip(±seconds)`,\n`playRange(start, end?)` (stops at `end`), `playRegion(id)`,\n`setRate(n)`, `setMuted(bool)`, `addMarker()` (as the button),\n`getTime()`, `getDuration()`, `isPlaying()`, `isReady()`. Calls before\nthe audio is ready do nothing.\n\n### Keys\n\nOn the waveform (a slider, one Tab stop): ← / → 5 seconds, Page Down /\nPage Up `skipSeconds`, Home / End, Space or Enter to play or pause.\nAnywhere in the player (not while typing, not with ⌘ / Ctrl / Alt): K\nplay / pause, J / L back / forward `skipSeconds`, M add a marker. Pins\nand region labels are buttons in Tab order.\n\n## Example\n\nA call recording with its topics and comments, read with the hooks of\n`lib/data.js` and saved with `lib/actions.js` in a Makersclaw app.\n\n```jsx\n// surfaces/call.jsx — client: one call, its topics and comments\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow, useRows, whenLanded } from \"../lib/data\";\nimport { insertRow } from \"../lib/actions\";\nimport { AudioPlayer } from \"../components/blocks/audio-player\";\n\nexport default function CallRecording() {\n const id = useSearchParams().get(\"row\");\n const { row: call, loading } = useRow({ source: \"calls\", id });\n const { rows: topics } = useRows({ source: \"call_topics\", filter: { call_id: id }, orderBy: \"start_s asc\" });\n const { rows: comments } = useRows({ source: \"call_comments\", filter: { call_id: id }, orderBy: \"at_s asc\" });\n if (loading || !call) return null;\n return (\n <AudioPlayer\n src={call.recording_url}\n title={call.title}\n description={`${call.participants} · ${call.date}`}\n regions={topics.map((t) => ({ id: t.id, start: t.start_s, end: t.end_s, label: t.name, tone: \"chart-2\" }))}\n markers={comments.map((c) => ({ id: c.id, time: c.at_s, label: c.body, tone: \"primary\" }))}\n onAddMarker={async ({ time }) => {\n const res = await insertRow({ source: \"call_comments\", values: { call_id: call.id, at_s: time, body: \"New comment\" } });\n if (res.ok) await whenLanded(); // the comments are read again: the new marker is in them\n return res;\n }}\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Record from the microphone, or play video.\n- Zoom or scroll the waveform: the whole recording always fits the\n width (a long call reads as an overview; use `peaks` so it draws fast).\n- Move, resize, rename or delete a region, or edit a marker, inside the\n player: those are your list's actions — change `regions` / `markers`.\n- Show a volume slider (mute only; the device's volume does the rest).\n- Stream: the file is fetched and decoded in the browser (pass `peaks`\n and `duration` for long files).\n"
|
|
50
50
|
}
|
|
51
51
|
],
|
|
52
52
|
"categories": [
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"docs": "Installed to components/makersclaw/blocks/audio-player. Import: import { AudioPlayer, useAudioPlayer, formatTime, formatRange, spokenTime, regionAt, TONES, RATES } from \"@/components/makersclaw/blocks/audio-player\". Props and an example: components/makersclaw/blocks/audio-player/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
68
68
|
"meta": {
|
|
69
69
|
"makersclaw": {
|
|
70
|
-
"version": "0.
|
|
70
|
+
"version": "0.7.0",
|
|
71
71
|
"useWhen": "A screen plays a recording: a call or meeting recording, a voice note, a podcast clip, a voicemail, a sound file on a record. The call-review block is built on it. Not for video, not for recording audio from the microphone."
|
|
72
72
|
}
|
|
73
73
|
}
|
package/r/auth-form.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "AuthForm",
|
|
6
6
|
"description": "The account screens in one form: sign in (password, email only, or provider buttons for Google, GitHub, Apple and Microsoft), sign up with extra fields, a magic link, a reset link, and changing a password against a live rules checklist. The auth is yours: each submit is an async callback that returns { ok } or an error the form shows.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"docs": "Installed to components/makersclaw/blocks/auth-form. Import: import { AuthForm, PASSWORD_RULES } from \"@/components/makersclaw/blocks/auth-form\". Props and an example: components/makersclaw/blocks/auth-form/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
49
49
|
"meta": {
|
|
50
50
|
"makersclaw": {
|
|
51
|
-
"version": "0.
|
|
51
|
+
"version": "0.7.0",
|
|
52
52
|
"useWhen": "An app needs a sign-in, sign-up, forgot-password or change-password screen, or a settings page where a signed-in person changes their password."
|
|
53
53
|
}
|
|
54
54
|
}
|
package/r/board.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "Board",
|
|
6
6
|
"description": "Records as cards in columns, one per stage; drag a card to another column (mouse, touch, or the card's menu from the keyboard) and the stage is saved. Optional column icons, a per-column total (\"13pt\"), a category colour on each card's edge, and a stacked layout of folding sections with compact rows.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
"path": "registry/blocks/board/board.jsx",
|
|
20
20
|
"type": "registry:component",
|
|
21
21
|
"target": "@components/makersclaw/blocks/board/board.jsx",
|
|
22
|
-
"content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { isSample } from \"../../parts\";\nimport { Button, Collapsible, Icon, Input, Menu, Notice, cx } from \"../../ui\";\n\n// Board — records as cards in columns, one column per stage, moved by\n// dragging or from the card's menu. The block owns the behaviour a board\n// gets wrong when it is drawn by hand: pointer dragging that works with a\n// mouse and on a phone, a keyboard path (the menu), the move shown at\n// once and put back with a reason when the write fails, examples that\n// move on screen and save nothing, the count in every header, and cards\n// no stage claims kept in view instead of lost. The surface owns what a\n// card says under its title (renderDetails) and how a move is saved\n// (onMove): the block imports nothing but React, the kit and the parts,\n// so it runs in a Makersclaw app and in any shadcn project alike.\n//\n// <Board rows={deals} groupBy=\"stage\" columns={STAGES}\n// onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}\n// titleOf={(d) => d.name} renderDetails={(d) => <DealFacts deal={d} />}\n// onOpen={setEditing} />\n//\n// Opt-in: an icon per column (`columns[].icon`), a figure beside each\n// count (`aggregate(rows)` → \"13pt\"), a category's colour on each card's\n// edge (`accentOf(row)` → 1…5, `accentEdge` top | start), and\n// `layout=\"stacked\"` — the stages as folding sections of compact rows.\n//\n// README.md beside this file lists every prop.\n\nconst OTHER = \"__other\";\n\n// A card's accent: a category's colour on one edge, from the chart tokens\n// (chart-1…5), written out whole so Tailwind finds every class.\nconst ACCENTS = {\n top: {\n 1: \"border-t-[3px] border-t-chart-1\",\n 2: \"border-t-[3px] border-t-chart-2\",\n 3: \"border-t-[3px] border-t-chart-3\",\n 4: \"border-t-[3px] border-t-chart-4\",\n 5: \"border-t-[3px] border-t-chart-5\",\n },\n start: {\n 1: \"border-s-[3px] border-s-chart-1\",\n 2: \"border-s-[3px] border-s-chart-2\",\n 3: \"border-s-[3px] border-s-chart-3\",\n 4: \"border-s-[3px] border-s-chart-4\",\n 5: \"border-s-[3px] border-s-chart-5\",\n },\n};\n\nfunction accentHue(value) {\n const n = typeof value === \"string\" ? Number(value.replace(/^chart-/, \"\")) : value;\n return Number.isInteger(n) && n >= 1 && n <= 5 ? n : null;\n}\nconst DRAG_START_PX = { mouse: 5, pen: 6, touch: 8 };\n\nfunction humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"—\";\n}\n\nfunction normalizeColumns(columns) {\n return (columns ?? []).map((c) =>\n typeof c === \"string\" || typeof c === \"number\"\n ? { key: String(c), label: humanize(c) }\n : { ...c, key: String(c.key), label: c.label ?? humanize(c.key) },\n );\n}\n\nexport function Board({\n rows,\n groupBy,\n columns,\n idColumn = \"id\",\n onMove,\n renderDetails,\n titleOf = (row) => row.title ?? row.name ?? String(row[idColumn] ?? \"\"),\n metaOf,\n onOpen,\n actionsOf,\n onCreate,\n addLabel = \"Add\",\n createPlaceholder = \"Title, then Enter\",\n limits,\n aggregate,\n accentOf,\n accentEdge = \"top\",\n layout = \"columns\",\n defaultOpen,\n label = \"Board\",\n emptyLabel = \"Nothing here yet\",\n otherLabel = \"No stage\",\n className,\n}) {\n const uid = useId();\n const boardRef = useRef(null);\n const dragRef = useRef(null);\n const suppressClick = useRef(false);\n const [overrides, setOverrides] = useState(() => new Map());\n const [drag, setDrag] = useState(null);\n const [error, setError] = useState(null);\n const [announce, setAnnounce] = useState(\"\");\n const [adding, setAdding] = useState(null);\n const [draft, setDraft] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [portal, setPortal] = useState(null);\n\n const movable = typeof onMove === \"function\";\n const list = rows ?? [];\n const keyOf = (row) => String(row?.[idColumn]);\n const cols = useMemo(() => normalizeColumns(columns), [columns]);\n const known = useMemo(() => new Set(cols.map((c) => c.key)), [cols]);\n const columnOf = (row) => {\n const moved = overrides.get(keyOf(row));\n const value = moved ?? (row?.[groupBy] == null ? \"\" : String(row[groupBy]));\n return known.has(value) ? value : OTHER;\n };\n\n useEffect(() => setPortal(document.body), []);\n\n // A move stays on screen until the rows the page re-reads carry it; then\n // the override has nothing left to say.\n useEffect(() => {\n setOverrides((current) => {\n if (current.size === 0) return current;\n const next = new Map(current);\n for (const [id, to] of current) {\n const row = list.find((r) => keyOf(r) === id);\n if (!row || String(row[groupBy]) === to) next.delete(id);\n }\n return next.size === current.size ? current : next;\n });\n // `list` changes identity with every read; that is the point.\n }, [rows, groupBy]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const byColumn = useMemo(() => {\n const out = new Map(cols.map((c) => [c.key, []]));\n out.set(OTHER, []);\n for (const row of list) out.get(columnOf(row)).push(row);\n return out;\n }, [list, cols, overrides]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const stacked = layout === \"stacked\";\n const accent = (row, edge = accentEdge) => {\n if (!accentOf) return null;\n const hue = accentHue(accentOf(row));\n return hue ? (ACCENTS[edge] ?? ACCENTS.top)[hue] : null;\n };\n const shown = byColumn.get(OTHER).length ? [...cols, { key: OTHER, label: otherLabel, other: true }] : cols;\n const labelOf = (key) => shown.find((c) => c.key === key)?.label ?? humanize(key);\n\n async function move(row, to) {\n const from = columnOf(row);\n if (!movable || to === from || to === OTHER || !known.has(to)) return;\n const id = keyOf(row);\n const title = titleOf(row);\n setError(null);\n setOverrides((m) => new Map(m).set(id, to));\n setAnnounce(`${title} moved to ${labelOf(to)}`);\n // An example moves on screen and saves nothing: it is not a row.\n if (isSample(row)) return;\n let result;\n try {\n result = await onMove(row, to, from === OTHER ? null : from);\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n if (result && result.ok === false) {\n setOverrides((m) => {\n const next = new Map(m);\n next.delete(id);\n return next;\n });\n setError(`“${title}” is back in ${labelOf(from)}: ${result.error ?? \"the move was not saved\"}`);\n setAnnounce(`${title} could not be moved`);\n }\n }\n\n // ---- pointer dragging ---------------------------------------------------\n // A mouse drags a card from anywhere on it; touch and pen drag from the\n // grip, so the column still scrolls under a finger.\n\n function onPointerDown(e, row) {\n if (!movable) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n const onGrip = Boolean(e.target.closest?.(\"[data-board-grip]\"));\n if (e.pointerType !== \"mouse\" && !onGrip) return;\n if (!onGrip && e.target.closest?.(\"input, textarea, select, a, [aria-haspopup], [role=menuitem]\")) return;\n // No text selection while a mouse drags; the click still lands.\n if (e.pointerType === \"mouse\") e.preventDefault();\n const card = e.currentTarget;\n const rect = card.getBoundingClientRect();\n dragRef.current = {\n row,\n id: keyOf(row),\n from: columnOf(row),\n pointerId: e.pointerId,\n type: e.pointerType,\n card,\n startX: e.clientX,\n startY: e.clientY,\n offsetX: e.clientX - rect.left,\n offsetY: e.clientY - rect.top,\n width: rect.width,\n active: false,\n over: null,\n x: e.clientX,\n y: e.clientY,\n };\n }\n\n function columnAt(x, y) {\n const el = document.elementFromPoint(x, y);\n const col = el?.closest?.(\"[data-board-column]\");\n return col && boardRef.current?.contains(col) ? col.getAttribute(\"data-board-column\") : null;\n }\n\n function onPointerMove(e) {\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n if (!d.active) {\n const far = Math.hypot(e.clientX - d.startX, e.clientY - d.startY) >= (DRAG_START_PX[d.type] ?? 5);\n if (!far) return;\n d.active = true;\n try {\n d.card.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is already gone; the move below still works\n }\n }\n e.preventDefault();\n d.x = e.clientX;\n d.y = e.clientY;\n d.over = columnAt(e.clientX, e.clientY);\n const board = boardRef.current;\n if (board) {\n const r = board.getBoundingClientRect();\n if (e.clientX < r.left + 48) board.scrollBy({ left: -18 });\n else if (e.clientX > r.right - 48) board.scrollBy({ left: 18 });\n }\n setDrag({ id: d.id, over: d.over, x: d.x, y: d.y, offsetX: d.offsetX, offsetY: d.offsetY, width: d.width, row: d.row });\n }\n\n function finish(e, drop) {\n const d = dragRef.current;\n if (!d || (e && e.pointerId !== d.pointerId)) return;\n dragRef.current = null;\n if (!d.active) return;\n suppressClick.current = true;\n setTimeout(() => {\n suppressClick.current = false;\n }, 0);\n setDrag(null);\n if (drop && d.over) move(d.row, d.over);\n }\n\n useEffect(() => {\n if (!drag) return undefined;\n const onKey = (e) => {\n if (e.key === \"Escape\") finish(null, false);\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [drag]); // eslint-disable-line react-hooks/exhaustive-deps\n\n async function create(e, column) {\n e.preventDefault();\n const title = draft.trim();\n if (!title || busy) return;\n setBusy(true);\n setError(null);\n let result;\n try {\n result = await onCreate(title, column);\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n setBusy(false);\n if (result && result.ok === false) setError(`“${title}” was not added: ${result.error ?? \"the write failed\"}`);\n else {\n setDraft(\"\");\n setAnnounce(`${title} added to ${labelOf(column)}`);\n }\n }\n\n // The \"Add\" at a stage's foot, and the inline field it opens.\n function adder(col, headingId, pad = \"px-2 pb-2\") {\n if (!onCreate || col.other) return null;\n if (adding !== col.key) {\n return (\n <div className={pad}>\n <Button\n variant=\"quiet\"\n size=\"sm\"\n icon=\"plus\"\n align=\"start\"\n className=\"w-full\"\n onClick={() => {\n setAdding(col.key);\n setDraft(\"\");\n }}\n >\n {addLabel}\n </Button>\n </div>\n );\n }\n return (\n <form onSubmit={(e) => create(e, col.key)} className={pad}>\n <label htmlFor={`${headingId}-new`} className=\"sr-only\">\n New in {col.label}\n </label>\n <Input\n id={`${headingId}-new`}\n autoFocus\n value={draft}\n disabled={busy}\n placeholder={createPlaceholder}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => {\n // Enter adds here, not through the form's implicit submit: a\n // synthetic Enter (an agent testing the app) has no keypress to\n // submit with. Never while an input method is composing a word.\n if (e.key === \"Enter\" && !e.nativeEvent.isComposing) create(e, col.key);\n else if (e.key === \"Escape\") {\n setAdding(null);\n setDraft(\"\");\n }\n }}\n onBlur={() => {\n if (!draft.trim()) setAdding(null);\n }}\n />\n </form>\n );\n }\n\n function header(col, cards) {\n const limit = limits?.[col.key];\n const over = typeof limit === \"number\" && cards.length > limit;\n return {\n icon: col.icon ? <Icon name={col.icon} size={16} className=\"shrink-0 text-muted-foreground\" /> : null,\n total: aggregate ? (\n <span data-nums className=\"shrink-0 text-xs text-muted-foreground\">\n {aggregate(cards, col.other ? null : col.key)}\n </span>\n ) : null,\n count: (\n <span\n data-nums\n className={cx(\"shrink-0 font-mono text-xs\", over ? \"font-semibold text-warning\" : \"text-muted-foreground\")}\n title={typeof limit === \"number\" ? `${cards.length} of a limit of ${limit}` : undefined}\n >\n {typeof limit === \"number\" ? `${cards.length}/${limit}` : cards.length}\n </span>\n ),\n };\n }\n\n function moveItems(row) {\n return movable\n ? shown\n .filter((c) => !c.other && c.key !== columnOf(row))\n .map((c) => ({ label: `Move to ${c.label}`, icon: \"arrow-right\", onSelect: () => move(row, c.key) }))\n : [];\n }\n\n function titleButton(row, title, extra) {\n return onOpen ? (\n <button\n type=\"button\"\n onClick={() => onOpen(row)}\n className={cx(\"block w-full rounded-sm text-left text-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring\", extra)}\n >\n {title}\n </button>\n ) : (\n <p className={cx(\"text-sm font-medium\", extra)}>{title}</p>\n );\n }\n\n function card(row, { ghost = false } = {}) {\n const title = titleOf(row);\n const body = (\n <>\n {titleButton(row, title)}\n {renderDetails ? (\n <div className=\"mt-2\">{renderDetails(row, { column: columnOf(row), layout: \"columns\" })}</div>\n ) : metaOf ? (\n <div className=\"mt-1 text-xs text-muted-foreground\">{metaOf(row)}</div>\n ) : null}\n </>\n );\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <div className=\"flex items-start gap-1\">\n {movable && !ghost ? (\n <span\n data-board-grip\n aria-hidden=\"true\"\n className=\"-ml-1 mt-0.5 hidden h-8 w-6 shrink-0 cursor-grab items-center justify-center text-muted-foreground [@media(hover:none)]:flex\"\n style={{ touchAction: \"none\" }}\n >\n <Icon name=\"grip\" size={16} />\n </span>\n ) : null}\n <div className=\"min-w-0 flex-1\">{body}</div>\n {!ghost ? <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 -mt-2 -mb-2 shrink-0\" /> : null}\n </div>\n );\n }\n\n const notice = error ? (\n <Notice tone=\"danger\" onDismiss={() => setError(null)}>\n {error}\n </Notice>\n ) : null;\n const live = (\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n );\n\n // Stacked: the stages one under another, each a section that folds, the\n // records as compact rows. Nothing drags here — a row moves through its\n // menu (\"Move to …\"), the same path the keyboard takes on the columns —\n // so a phone scrolls the page, never a board sideways.\n if (stacked) {\n const opens = defaultOpen == null ? null : new Set((Array.isArray(defaultOpen) ? defaultOpen : [defaultOpen]).map(String));\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div role=\"region\" aria-label={label} className=\"overflow-hidden rounded-lg border border-border bg-card text-card-foreground\">\n {shown.map((col) => {\n const rowsHere = byColumn.get(col.key) ?? [];\n const head = header(col, rowsHere);\n const headingId = `${uid}-${col.key}`;\n return (\n <Collapsible\n key={col.key}\n variant=\"flush\"\n defaultOpen={opens ? opens.has(col.key) : true}\n title={\n <span className=\"flex min-w-0 items-center gap-2\">\n {head.icon}\n <span id={headingId} className=\"truncate font-semibold\">\n {col.label}\n </span>\n </span>\n }\n meta={\n <span className=\"flex items-center gap-2\">\n {head.total}\n {head.count}\n </span>\n }\n >\n <ul aria-labelledby={headingId} className={cx(\"-mx-4 divide-y divide-border border-t border-border\", !onCreate || col.other ? \"-mb-4\" : \"mb-2\")}>\n {rowsHere.map((row) => {\n const id = keyOf(row);\n const title = titleOf(row);\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <li key={id} data-board-card={id} className={cx(\"flex min-h-11 items-center gap-3 px-4 py-1.5\", accent(row, \"start\"))}>\n <div className=\"min-w-0 flex-1\">\n {titleButton(row, title, \"truncate\")}\n {metaOf ? <div className=\"truncate text-xs text-muted-foreground\">{metaOf(row)}</div> : null}\n </div>\n {renderDetails ? (\n <div className=\"flex shrink-0 items-center gap-2\">{renderDetails(row, { column: columnOf(row), layout: \"stacked\" })}</div>\n ) : null}\n <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 shrink-0\" />\n </li>\n );\n })}\n {rowsHere.length === 0 ? <li className=\"px-4 py-3 text-xs text-muted-foreground\">{emptyLabel}</li> : null}\n </ul>\n {adder(col, headingId, \"-mx-2\")}\n </Collapsible>\n );\n })}\n </div>\n {live}\n </div>\n );\n }\n\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div\n ref={boardRef}\n role=\"region\"\n aria-label={label}\n tabIndex={0}\n className=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-lg pb-2 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:snap-none\"\n >\n {shown.map((col) => {\n const cards = byColumn.get(col.key) ?? [];\n const head = header(col, cards);\n const target = drag && drag.over === col.key && !col.other && columnOf(drag.row) !== col.key;\n const headingId = `${uid}-${col.key}`;\n return (\n <section\n key={col.key}\n data-board-column={col.other ? undefined : col.key}\n aria-labelledby={headingId}\n className={cx(\n \"flex max-h-[75dvh] w-[min(18rem,85vw)] shrink-0 snap-start flex-col rounded-lg border border-border bg-muted/50\",\n target && \"bg-accent ring-2 ring-ring\",\n )}\n >\n <header className=\"flex min-h-11 items-center gap-2 px-3\">\n {head.icon}\n <h3 id={headingId} className=\"min-w-0 flex-1 truncate text-sm font-semibold\">\n {col.label}\n </h3>\n {head.total}\n {head.count}\n </header>\n <ul className=\"flex min-h-16 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2\">\n {cards.map((row) => {\n const id = keyOf(row);\n const lifted = drag?.id === id;\n return (\n <li\n key={id}\n data-board-card={id}\n onPointerDown={(e) => onPointerDown(e, row)}\n onPointerMove={onPointerMove}\n onPointerUp={(e) => finish(e, true)}\n onPointerCancel={(e) => finish(e, false)}\n onClickCapture={(e) => {\n if (suppressClick.current) {\n e.preventDefault();\n e.stopPropagation();\n }\n }}\n className={cx(\n \"rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm\",\n accent(row),\n movable && \"sm:cursor-grab\",\n lifted && \"opacity-40\",\n )}\n >\n {card(row)}\n </li>\n );\n })}\n {cards.length === 0 ? (\n <li className=\"rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground\">{emptyLabel}</li>\n ) : null}\n </ul>\n {adder(col, headingId)}\n </section>\n );\n })}\n </div>\n {live}\n {drag && portal\n ? createPortal(\n <div\n aria-hidden=\"true\"\n className={cx(\n \"pointer-events-none fixed z-50 rotate-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg\",\n accent(drag.row),\n )}\n style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}\n >\n {card(drag.row, { ghost: true })}\n </div>,\n portal,\n )\n : null}\n </div>\n );\n}\n"
|
|
22
|
+
"content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { isSample } from \"../../parts\";\nimport { Button, Collapsible, Icon, Input, Menu, Notice, cx } from \"../../ui\";\n\n// Board — records as cards in columns, one column per stage, moved by\n// dragging or from the card's menu. The block owns the behaviour a board\n// gets wrong when it is drawn by hand: pointer dragging that works with a\n// mouse and on a phone, a keyboard path (the menu), the move shown at\n// once and put back with a reason when the write fails, examples that\n// move on screen and save nothing, the count in every header, and cards\n// no stage claims kept in view instead of lost. The surface owns what a\n// card says under its title (renderDetails) and how a move is saved\n// (onMove): the block imports nothing but React, the kit and the parts,\n// so it runs in a Makersclaw app and in any shadcn project alike.\n//\n// <Board rows={deals} groupBy=\"stage\" columns={STAGES}\n// onMove={(d, to) => moveRow({ source: \"deals\", id: d.id, stateColumn: \"stage\", to })}\n// titleOf={(d) => d.name} renderDetails={(d) => <DealFacts deal={d} />}\n// onOpen={setEditing} />\n//\n// Opt-in: an icon per column (`columns[].icon`), a figure beside each\n// count (`aggregate(rows)` → \"13pt\"), a category's colour on each card's\n// edge (`accentOf(row)` → 1…5, `accentEdge` top | start), and\n// `layout=\"stacked\"` — the stages as folding sections of compact rows.\n//\n// README.md beside this file lists every prop.\n\nconst OTHER = \"__other\";\n\n// A card's accent: a category's colour on one edge, from the chart tokens\n// (chart-1…5), written out whole so Tailwind finds every class.\nconst ACCENTS = {\n top: {\n 1: \"border-t-[3px] border-t-chart-1\",\n 2: \"border-t-[3px] border-t-chart-2\",\n 3: \"border-t-[3px] border-t-chart-3\",\n 4: \"border-t-[3px] border-t-chart-4\",\n 5: \"border-t-[3px] border-t-chart-5\",\n },\n start: {\n 1: \"border-s-[3px] border-s-chart-1\",\n 2: \"border-s-[3px] border-s-chart-2\",\n 3: \"border-s-[3px] border-s-chart-3\",\n 4: \"border-s-[3px] border-s-chart-4\",\n 5: \"border-s-[3px] border-s-chart-5\",\n },\n};\n\nfunction accentHue(value) {\n const n = typeof value === \"string\" ? Number(value.replace(/^chart-/, \"\")) : value;\n return Number.isInteger(n) && n >= 1 && n <= 5 ? n : null;\n}\nconst DRAG_START_PX = { mouse: 5, pen: 6, touch: 8 };\n\nfunction humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"—\";\n}\n\nfunction normalizeColumns(columns) {\n return (columns ?? []).map((c) =>\n typeof c === \"string\" || typeof c === \"number\"\n ? { key: String(c), label: humanize(c) }\n : { ...c, key: String(c.key), label: c.label ?? humanize(c.key) },\n );\n}\n\nexport function Board({\n rows,\n groupBy,\n columns,\n idColumn = \"id\",\n onMove,\n renderDetails,\n titleOf = (row) => row.title ?? row.name ?? String(row[idColumn] ?? \"\"),\n metaOf,\n onOpen,\n actionsOf,\n onCreate,\n addLabel = \"Add\",\n createPlaceholder = \"Title, then Enter\",\n limits,\n aggregate,\n accentOf,\n accentEdge = \"top\",\n layout = \"columns\",\n defaultOpen,\n label = \"Board\",\n emptyLabel = \"Nothing here yet\",\n otherLabel = \"No stage\",\n className,\n}) {\n const uid = useId();\n const boardRef = useRef(null);\n const dragRef = useRef(null);\n const suppressClick = useRef(false);\n const [overrides, setOverrides] = useState(() => new Map());\n const [drag, setDrag] = useState(null);\n const [error, setError] = useState(null);\n const [announce, setAnnounce] = useState(\"\");\n const [adding, setAdding] = useState(null);\n const [draft, setDraft] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [portal, setPortal] = useState(null);\n\n const movable = typeof onMove === \"function\";\n const list = rows ?? [];\n const keyOf = (row) => String(row?.[idColumn]);\n const cols = useMemo(() => normalizeColumns(columns), [columns]);\n const known = useMemo(() => new Set(cols.map((c) => c.key)), [cols]);\n const columnOf = (row) => {\n const moved = overrides.get(keyOf(row));\n const value = moved ?? (row?.[groupBy] == null ? \"\" : String(row[groupBy]));\n return known.has(value) ? value : OTHER;\n };\n\n useEffect(() => setPortal(document.body), []);\n\n // A move stays on screen until the rows the page re-reads carry it; then\n // the override has nothing left to say.\n useEffect(() => {\n setOverrides((current) => {\n if (current.size === 0) return current;\n const next = new Map(current);\n for (const [id, to] of current) {\n const row = list.find((r) => keyOf(r) === id);\n if (!row || String(row[groupBy]) === to) next.delete(id);\n }\n return next.size === current.size ? current : next;\n });\n // `list` changes identity with every read; that is the point.\n }, [rows, groupBy]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const byColumn = useMemo(() => {\n const out = new Map(cols.map((c) => [c.key, []]));\n out.set(OTHER, []);\n for (const row of list) out.get(columnOf(row)).push(row);\n return out;\n }, [list, cols, overrides]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const stacked = layout === \"stacked\";\n const accent = (row, edge = accentEdge) => {\n if (!accentOf) return null;\n const hue = accentHue(accentOf(row));\n return hue ? (ACCENTS[edge] ?? ACCENTS.top)[hue] : null;\n };\n const shown = byColumn.get(OTHER).length ? [...cols, { key: OTHER, label: otherLabel, other: true }] : cols;\n const labelOf = (key) => shown.find((c) => c.key === key)?.label ?? humanize(key);\n\n async function move(row, to) {\n const from = columnOf(row);\n if (!movable || to === from || to === OTHER || !known.has(to)) return;\n const id = keyOf(row);\n const title = titleOf(row);\n setError(null);\n setOverrides((m) => new Map(m).set(id, to));\n setAnnounce(`${title} moved to ${labelOf(to)}`);\n // An example moves on screen and saves nothing: it is not a row.\n if (isSample(row)) return;\n let result;\n try {\n result = await onMove(row, to, from === OTHER ? null : from);\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n if (result && result.ok === false) {\n setOverrides((m) => {\n const next = new Map(m);\n next.delete(id);\n return next;\n });\n setError(`“${title}” is back in ${labelOf(from)}: ${result.error ?? \"the move was not saved\"}`);\n setAnnounce(`${title} could not be moved`);\n }\n }\n\n // ---- pointer dragging ---------------------------------------------------\n // A mouse drags a card from anywhere on it; touch and pen drag from the\n // grip, so the column still scrolls under a finger.\n\n function onPointerDown(e, row) {\n if (!movable) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n const onGrip = Boolean(e.target.closest?.(\"[data-board-grip]\"));\n if (e.pointerType !== \"mouse\" && !onGrip) return;\n if (!onGrip && e.target.closest?.(\"input, textarea, select, a, [aria-haspopup], [role=menuitem]\")) return;\n // No text selection while a mouse drags; the click still lands.\n if (e.pointerType === \"mouse\") e.preventDefault();\n const card = e.currentTarget;\n const rect = card.getBoundingClientRect();\n dragRef.current = {\n row,\n id: keyOf(row),\n from: columnOf(row),\n pointerId: e.pointerId,\n type: e.pointerType,\n card,\n startX: e.clientX,\n startY: e.clientY,\n offsetX: e.clientX - rect.left,\n offsetY: e.clientY - rect.top,\n width: rect.width,\n active: false,\n over: null,\n x: e.clientX,\n y: e.clientY,\n };\n }\n\n function columnAt(x, y) {\n const el = document.elementFromPoint(x, y);\n const col = el?.closest?.(\"[data-board-column]\");\n return col && boardRef.current?.contains(col) ? col.getAttribute(\"data-board-column\") : null;\n }\n\n function onPointerMove(e) {\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n if (!d.active) {\n const far = Math.hypot(e.clientX - d.startX, e.clientY - d.startY) >= (DRAG_START_PX[d.type] ?? 5);\n if (!far) return;\n d.active = true;\n try {\n d.card.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is already gone; the move below still works\n }\n }\n e.preventDefault();\n d.x = e.clientX;\n d.y = e.clientY;\n d.over = columnAt(e.clientX, e.clientY);\n const board = boardRef.current;\n if (board) {\n const r = board.getBoundingClientRect();\n if (e.clientX < r.left + 48) board.scrollBy({ left: -18 });\n else if (e.clientX > r.right - 48) board.scrollBy({ left: 18 });\n }\n setDrag({ id: d.id, over: d.over, x: d.x, y: d.y, offsetX: d.offsetX, offsetY: d.offsetY, width: d.width, row: d.row });\n }\n\n function finish(e, drop) {\n const d = dragRef.current;\n if (!d || (e && e.pointerId !== d.pointerId)) return;\n dragRef.current = null;\n if (!d.active) return;\n suppressClick.current = true;\n setTimeout(() => {\n suppressClick.current = false;\n }, 0);\n setDrag(null);\n if (drop && d.over) move(d.row, d.over);\n }\n\n useEffect(() => {\n if (!drag) return undefined;\n const onKey = (e) => {\n if (e.key === \"Escape\") finish(null, false);\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [drag]); // eslint-disable-line react-hooks/exhaustive-deps\n\n async function create(e, column) {\n e.preventDefault();\n const title = draft.trim();\n if (!title || busy) return;\n setBusy(true);\n setError(null);\n let result;\n try {\n result = await onCreate(title, column);\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n setBusy(false);\n if (result && result.ok === false) setError(`“${title}” was not added: ${result.error ?? \"the write failed\"}`);\n else {\n setDraft(\"\");\n setAnnounce(`${title} added to ${labelOf(column)}`);\n }\n }\n\n // The \"Add\" at a stage's foot, and the inline field it opens.\n function adder(col, headingId, pad = \"px-2 pb-2\") {\n if (!onCreate || col.other) return null;\n if (adding !== col.key) {\n return (\n <div className={pad}>\n <Button\n variant=\"quiet\"\n size=\"sm\"\n icon=\"plus\"\n align=\"start\"\n className=\"w-full\"\n onClick={() => {\n setAdding(col.key);\n setDraft(\"\");\n }}\n >\n {addLabel}\n </Button>\n </div>\n );\n }\n return (\n <form onSubmit={(e) => create(e, col.key)} className={pad}>\n <label htmlFor={`${headingId}-new`} className=\"sr-only\">\n New in {col.label}\n </label>\n <Input\n id={`${headingId}-new`}\n autoFocus\n value={draft}\n disabled={busy}\n placeholder={createPlaceholder}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => {\n // Enter adds here, not through the form's implicit submit: a\n // synthetic Enter (an agent testing the app) has no keypress to\n // submit with. Never while an input method is composing a word.\n if (e.key === \"Enter\" && !e.nativeEvent.isComposing) create(e, col.key);\n else if (e.key === \"Escape\") {\n setAdding(null);\n setDraft(\"\");\n }\n }}\n onBlur={() => {\n if (!draft.trim()) setAdding(null);\n }}\n />\n </form>\n );\n }\n\n function header(col, cards) {\n const limit = limits?.[col.key];\n const over = typeof limit === \"number\" && cards.length > limit;\n return {\n icon: col.icon ? <Icon name={col.icon} size={16} className=\"shrink-0 text-muted-foreground\" /> : null,\n total: aggregate ? (\n <span data-nums className=\"shrink-0 text-xs text-muted-foreground\">\n {aggregate(cards, col.other ? null : col.key)}\n </span>\n ) : null,\n count: (\n <span\n data-nums\n className={cx(\"shrink-0 font-mono text-xs\", over ? \"font-semibold text-warning\" : \"text-muted-foreground\")}\n title={typeof limit === \"number\" ? `${cards.length} of a limit of ${limit}` : undefined}\n >\n {typeof limit === \"number\" ? `${cards.length}/${limit}` : cards.length}\n </span>\n ),\n };\n }\n\n function moveItems(row) {\n return movable\n ? shown\n .filter((c) => !c.other && c.key !== columnOf(row))\n .map((c) => ({ label: `Move to ${c.label}`, icon: \"arrow-right\", onSelect: () => move(row, c.key) }))\n : [];\n }\n\n function titleButton(row, title, extra) {\n return onOpen ? (\n <button\n type=\"button\"\n onClick={() => onOpen(row)}\n className={cx(\"block w-full rounded-sm text-left text-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring\", extra)}\n >\n {title}\n </button>\n ) : (\n <p className={cx(\"text-sm font-medium\", extra)}>{title}</p>\n );\n }\n\n function card(row, { ghost = false } = {}) {\n const title = titleOf(row);\n const body = (\n <>\n {titleButton(row, title)}\n {renderDetails ? (\n <div className=\"mt-2\">{renderDetails(row, { column: columnOf(row), layout: \"columns\" })}</div>\n ) : metaOf ? (\n <div className=\"mt-1 text-xs text-muted-foreground\">{metaOf(row)}</div>\n ) : null}\n </>\n );\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <div className=\"flex items-start gap-1\">\n {movable && !ghost ? (\n <span\n data-board-grip\n aria-hidden=\"true\"\n className=\"-ml-1 mt-0.5 hidden h-8 w-6 shrink-0 cursor-grab items-center justify-center text-muted-foreground [@media(hover:none)]:flex\"\n style={{ touchAction: \"none\" }}\n >\n <Icon name=\"grip\" size={16} />\n </span>\n ) : null}\n <div className=\"min-w-0 flex-1\">{body}</div>\n {!ghost ? <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 -mt-2 -mb-2 shrink-0\" /> : null}\n </div>\n );\n }\n\n const notice = error ? (\n <Notice tone=\"danger\" onDismiss={() => setError(null)}>\n {error}\n </Notice>\n ) : null;\n const live = (\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n );\n\n // Stacked: the stages one under another, each a section that folds, the\n // records as compact rows. Nothing drags here — a row moves through its\n // menu (\"Move to …\"), the same path the keyboard takes on the columns —\n // so a phone scrolls the page, never a board sideways.\n if (stacked) {\n const opens = defaultOpen == null ? null : new Set((Array.isArray(defaultOpen) ? defaultOpen : [defaultOpen]).map(String));\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div role=\"region\" aria-label={label} className=\"overflow-hidden rounded-lg border border-border bg-card text-card-foreground\">\n {shown.map((col) => {\n const rowsHere = byColumn.get(col.key) ?? [];\n const head = header(col, rowsHere);\n const headingId = `${uid}-${col.key}`;\n return (\n <Collapsible\n key={col.key}\n variant=\"flush\"\n defaultOpen={opens ? opens.has(col.key) : true}\n title={\n <span className=\"flex min-w-0 items-center gap-2\">\n {head.icon}\n <span id={headingId} className=\"truncate font-semibold\">\n {col.label}\n </span>\n </span>\n }\n meta={\n <span className=\"flex items-center gap-2\">\n {head.total}\n {head.count}\n </span>\n }\n >\n <ul aria-labelledby={headingId} className={cx(\"-mx-4 divide-y divide-border border-t border-border\", !onCreate || col.other ? \"-mb-4\" : \"mb-2\")}>\n {rowsHere.map((row) => {\n const id = keyOf(row);\n const title = titleOf(row);\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <li key={id} data-board-card={id} className={cx(\"flex min-h-11 items-center gap-3 px-4 py-1.5\", accent(row, \"start\"))}>\n <div className=\"min-w-0 flex-1\">\n {titleButton(row, title, \"truncate\")}\n {metaOf ? <div className=\"truncate text-xs text-muted-foreground\">{metaOf(row)}</div> : null}\n </div>\n {renderDetails ? (\n <div className=\"flex shrink-0 items-center gap-2\">{renderDetails(row, { column: columnOf(row), layout: \"stacked\" })}</div>\n ) : null}\n <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 shrink-0\" />\n </li>\n );\n })}\n {rowsHere.length === 0 ? <li className=\"px-4 py-3 text-xs text-muted-foreground\">{emptyLabel}</li> : null}\n </ul>\n {adder(col, headingId, \"-mx-2\")}\n </Collapsible>\n );\n })}\n </div>\n {live}\n </div>\n );\n }\n\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div\n ref={boardRef}\n role=\"region\"\n aria-label={label}\n tabIndex={0}\n className=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-lg pb-2 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:snap-none\"\n >\n {shown.map((col) => {\n const cards = byColumn.get(col.key) ?? [];\n const head = header(col, cards);\n const target = drag && drag.over === col.key && !col.other && columnOf(drag.row) !== col.key;\n const headingId = `${uid}-${col.key}`;\n return (\n <section\n key={col.key}\n data-board-column={col.other ? undefined : col.key}\n aria-labelledby={headingId}\n className={cx(\n \"flex max-h-[75dvh] w-[min(18rem,85vw)] shrink-0 snap-start flex-col rounded-lg border border-border bg-muted/50\",\n target && \"bg-accent ring-2 ring-ring\",\n )}\n >\n <header className=\"flex min-h-11 items-center gap-2 px-3\">\n {head.icon}\n <h3 id={headingId} className=\"min-w-0 flex-1 truncate text-sm font-semibold\">\n {col.label}\n </h3>\n {head.total}\n {head.count}\n </header>\n <ul className=\"flex min-h-16 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2\">\n {cards.map((row) => {\n const id = keyOf(row);\n const lifted = drag?.id === id;\n return (\n <li\n key={id}\n data-board-card={id}\n onPointerDown={(e) => onPointerDown(e, row)}\n onPointerMove={onPointerMove}\n onPointerUp={(e) => finish(e, true)}\n onPointerCancel={(e) => finish(e, false)}\n onClickCapture={(e) => {\n if (suppressClick.current) {\n e.preventDefault();\n e.stopPropagation();\n }\n }}\n className={cx(\n \"rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm\",\n accent(row),\n movable && \"sm:cursor-grab\",\n lifted && \"opacity-40\",\n )}\n >\n {card(row)}\n </li>\n );\n })}\n {cards.length === 0 ? (\n <li className=\"rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground\">{emptyLabel}</li>\n ) : null}\n </ul>\n {adder(col, headingId)}\n </section>\n );\n })}\n </div>\n {live}\n {drag && portal\n ? createPortal(\n <div\n aria-hidden=\"true\"\n className={cx(\n \"pointer-events-none fixed z-50 rotate-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg\",\n accent(drag.row),\n )}\n style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}\n >\n {card(drag.row, { ghost: true })}\n </div>,\n portal,\n )\n : null}\n </div>\n );\n}\n"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "registry/blocks/board/README.md",
|
|
26
26
|
"type": "registry:file",
|
|
27
27
|
"target": "@components/makersclaw/blocks/board/README.md",
|
|
28
|
-
"content": "# Board\n\nRecords as cards in columns, one column per stage. Drag a card to another\ncolumn — with a mouse from anywhere on the card, on a phone from its grip,\nfrom the keyboard through the card's menu (\"Move to …\") — and the stage is\nsaved. The move shows at once; when the write fails the card goes back and\nthe board says why.\n\n```jsx\nimport { Board } from \"../components/blocks/board\"; // a Makersclaw app (\"./blocks/board\" from components/)\nimport { Board } from \"@/components/makersclaw/blocks/board\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4).\n\n## The data\n\nOne table, one column that holds the stage (`groupBy`). Each row is a card.\n`columns` is the stages in order — the values that column holds. A row\nwhose stage is not in `columns` lands in a last \"No stage\" column, so no\nrecord goes missing; it can be dragged out of it, not into it.\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n example cards move on screen without saving (they are not rows).\n- `groupBy` — the column that holds the stage, e.g. `\"stage\"`.\n- `columns` — the stages in order: `[\"lead\", \"call\", \"won\"]` (labels are\n made from the keys) or `[{ key: \"lead\", label: \"New lead\", icon? }, …]`.\n `icon` is a kit `Icon` name, drawn before the stage's label.\n- `onMove(row, to, from)` — save the move; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app it\n is one line over `moveRow` (lib/actions.js — the activity row included):\n `onMove={(d, to) => moveRow({ source: \"
|
|
28
|
+
"content": "# Board\n\nRecords as cards in columns, one column per stage. Drag a card to another\ncolumn — with a mouse from anywhere on the card, on a phone from its grip,\nfrom the keyboard through the card's menu (\"Move to …\") — and the stage is\nsaved. The move shows at once; when the write fails the card goes back and\nthe board says why.\n\n```jsx\nimport { Board } from \"../components/blocks/board\"; // a Makersclaw app (\"./blocks/board\" from components/)\nimport { Board } from \"@/components/makersclaw/blocks/board\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4).\n\n## The data\n\nOne table, one column that holds the stage (`groupBy`). Each row is a card.\n`columns` is the stages in order — the values that column holds. A row\nwhose stage is not in `columns` lands in a last \"No stage\" column, so no\nrecord goes missing; it can be dragged out of it, not into it.\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n example cards move on screen without saving (they are not rows).\n- `groupBy` — the column that holds the stage, e.g. `\"stage\"`.\n- `columns` — the stages in order: `[\"lead\", \"call\", \"won\"]` (labels are\n made from the keys) or `[{ key: \"lead\", label: \"New lead\", icon? }, …]`.\n `icon` is a kit `Icon` name, drawn before the stage's label.\n- `onMove(row, to, from)` — save the move; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app it\n is one line over `moveRow` (lib/actions.js — the activity row included):\n `onMove={(d, to) => moveRow({ source: \"deals\", id: d.id, stateColumn: \"stage\", to })}`.\n Without `onMove` the board only shows; nothing can be moved.\n- `idColumn` — default `\"id\"`.\n- `titleOf(row)` — the card's title, default `row.title ?? row.name`. It\n also names the card in its menu, in announcements and in errors.\n- `onOpen(row)` — the title becomes a button: open your `Sheet` to edit\n the record. The block draws that button; you never style one.\n- `renderDetails(row, { column, layout })` — what the card says under its\n title: the part of the board that is yours. Kit parts and tokens only.\n In `layout=\"stacked\"` it sits at the row's end instead, so keep it short\n there (a badge, an avatar); `layout` tells you which.\n- `metaOf(row)` — a plain muted line under the title, when that is all\n the card needs (ignored on cards when `renderDetails` is set; a stacked\n row shows both).\n- `actionsOf(row)` — more menu items for a card, kit `Menu` shape:\n `[{ label, onSelect, icon?, tone?: \"danger\" }]`. The \"Move to\" items\n follow them.\n- `onCreate(title, column)` — an \"Add\" at every column's foot opens an\n inline field; Enter calls this (insert the row with `insertRow`), the\n field stays open for the next one. Return `{ ok: false, error }` to show\n why it failed.\n- `addLabel` · `createPlaceholder` · `emptyLabel` · `otherLabel` — words.\n- `limits` — a work-in-progress limit per stage: `{ doing: 3 }` shows\n `4/3` in the header, in the warning tone, when it is exceeded.\n- `aggregate(rows, column)` — a figure beside each stage's count, from the\n records in it: ``(list) => `${list.reduce((s, r) => s + r.points, 0)}pt` ``\n → \"13pt\". A string or a node; `column` is null for \"No stage\".\n- `accentOf(row)` — the record's category as a colour on one edge of its\n card: `1` … `5` (the chart tokens, `chart-1` … `chart-5`), or `null` for\n none. A category, never a status: show what it means in words too (a\n legend, or the type on the card).\n- `accentEdge` — `\"top\"` (default) or `\"start\"` (the left edge, in a\n right-to-left page the right). Stacked rows always use the start edge.\n- `layout` — `\"columns\"` (default: side by side, dragged) or `\"stacked\"`:\n the stages one under another as folding sections (icon, label, count,\n chevron), each record a compact row. Rows move through their menu\n (\"Move to …\"); nothing drags, so on a phone the page just scrolls.\n- `defaultOpen` — stacked: the stage keys open at first, e.g.\n `[\"todo\"]`. Default: every stage open.\n- `label` — what the board is called to a screen reader.\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// surfaces/pipeline.jsx — client: read, hand over\n\"use client\";\nimport { useRows } from \"../lib/data\";\nimport Pipeline from \"../components/Pipeline\";\n\nexport default function PipelineSurface() {\n const { rows, error, loading } = useRows({ source: \"deals\", orderBy: \"updated_at desc\" });\n if (loading) return null; // rows only once read: to useFixture, a read in flight looks like an empty table\n return <Pipeline deals={rows} error={error} />;\n}\n```\n\n```jsx\n// components/Pipeline.jsx — client: the board, the card, the edit sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { insertRow, moveRow } from \"../lib/actions\";\nimport { Badge, Notice } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { Board } from \"./blocks/board\";\n\nconst STAGES = [\n { key: \"lead\", label: \"Lead\" },\n { key: \"call\", label: \"Call booked\" },\n { key: \"proposal\", label: \"Proposal\" },\n { key: \"won\", label: \"Won\" },\n];\nconst SAMPLE = [\n { name: \"Northwind\", stage: \"call\", value: 12000, owner: \"Maya\" },\n { name: \"Acme Ltd\", stage: \"lead\", value: 4000, owner: \"Leon\" },\n];\n\nexport default function Pipeline({ deals, error }) {\n const fixture = useFixture(deals, SAMPLE);\n const [editing, setEditing] = useState(null);\n return (\n <div className=\"grid gap-4\">\n {error ? <Notice tone=\"danger\">{error}</Notice> : null}\n {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}\n <Board\n rows={fixture.rows}\n groupBy=\"stage\"\n columns={STAGES}\n onMove={(d, to) => moveRow({ source: \"deals\", id: d.id, stateColumn: \"stage\", to })}\n titleOf={(d) => d.name}\n limits={{ proposal: 5 }}\n onCreate={(name, stage) => insertRow({ source: \"deals\", values: { name, stage }, detail: name })}\n onOpen={setEditing}\n renderDetails={(d) => (\n <div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n <span>{d.owner ?? \"Unowned\"}</span>\n <Badge>{Number(d.value ?? 0).toLocaleString()}</Badge>\n </div>\n )}\n label=\"Deals by stage\"\n />\n {/* <Sheet open={Boolean(editing)} …> the record's fields </Sheet> */}\n </div>\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\nA sprint (icons, points per stage, a top edge by type) and a stacked list:\n\n```jsx\nconst TYPE_HUE = { feature: 1, bug: 4, task: 2 };\n\n<Board\n rows={stories}\n groupBy=\"status\"\n columns={[{ key: \"todo\", label: \"To do\", icon: \"inbox\" }, { key: \"doing\", label: \"In progress\", icon: \"zap\" }, { key: \"done\", label: \"Done\", icon: \"circle-check\" }]}\n onMove={(s, to) => moveRow({ source: \"stories\", id: s.id, stateColumn: \"status\", to })}\n aggregate={(list) => `${list.reduce((n, s) => n + (s.points ?? 0), 0)}pt`}\n accentOf={(s) => TYPE_HUE[s.type] ?? null}\n renderDetails={(s) => <Badge>{s.points}pt</Badge>}\n label=\"Stories by status\"\n/>\n\n<Board\n layout=\"stacked\"\n rows={chores}\n groupBy=\"state\"\n columns={[{ key: \"todo\", label: \"To do\", icon: \"inbox\" }, { key: \"done\", label: \"Done\", icon: \"circle-check\" }]}\n defaultOpen={[\"todo\"]}\n onMove={(c, to) => moveRow({ source: \"chores\", id: c.id, stateColumn: \"state\", to })}\n metaOf={(c) => c.room}\n renderDetails={(c) => <Badge tone={c.every === \"daily\" ? \"warning\" : \"info\"}>{c.every}</Badge>}\n label=\"Chores by state\"\n/>\n```\n\n## What it will not do\n\n- Order cards inside a column: they keep the order of `rows` — sort in\n the surface's `loadRows`.\n- Create a stage: `columns` is the surface's; a new stage is a new entry.\n- Colour a column: stages are not status. Put a `Badge` on the card when a\n stage means something the founder must see. (`accentOf` colours a card\n by its category, not its stage.)\n- Drag in `layout=\"stacked\"`: rows move from their menu.\n\nA board it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add board` restores them.\n"
|
|
29
29
|
}
|
|
30
30
|
],
|
|
31
31
|
"categories": [
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"docs": "Installed to components/makersclaw/blocks/board. Import: import { Board } from \"@/components/makersclaw/blocks/board\". Props and an example: components/makersclaw/blocks/board/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
49
49
|
"meta": {
|
|
50
50
|
"makersclaw": {
|
|
51
|
-
"version": "0.
|
|
51
|
+
"version": "0.7.0",
|
|
52
52
|
"useWhen": "A process where each record sits in one stage and moves forward: a pipeline, a hiring funnel, a sprint, a content calendar by status, outreach by reply state. Not for records with no stages (a table) or items placed on a timeline."
|
|
53
53
|
}
|
|
54
54
|
}
|