makersclaw 0.1.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 +68 -0
- package/bin/makersclaw.mjs +10 -0
- package/package.json +34 -0
- package/registry/blocks/board/README.md +130 -0
- package/registry/blocks/board/block.json +10 -0
- package/registry/blocks/board/board.jsx +435 -0
- package/registry/blocks/board/demo.jsx +50 -0
- package/registry/blocks/board/index.js +4 -0
- package/registry/blocks/job-panel/README.md +68 -0
- package/registry/blocks/job-panel/block.json +10 -0
- package/registry/blocks/job-panel/demo.jsx +75 -0
- package/registry/blocks/job-panel/index.js +3 -0
- package/registry/blocks/job-panel/job-panel.jsx +110 -0
- package/src/cli.mjs +301 -0
- package/src/fsutil.mjs +76 -0
- package/src/install.mjs +457 -0
- package/src/registry.mjs +191 -0
- package/templates/next/files/README.md +626 -0
- package/templates/next/files/app/auto-refresh.jsx +26 -0
- package/templates/next/files/app/error-relay.jsx +53 -0
- package/templates/next/files/app/error.jsx +44 -0
- package/templates/next/files/app/global-error.jsx +36 -0
- package/templates/next/files/app/globals.css +178 -0
- package/templates/next/files/app/layout.jsx +51 -0
- package/templates/next/files/app/not-found.jsx +23 -0
- package/templates/next/files/app/page.jsx +22 -0
- package/templates/next/files/app/s/[key]/page.jsx +47 -0
- package/templates/next/files/app/shell.jsx +64 -0
- package/templates/next/files/check/run.mjs +683 -0
- package/templates/next/files/components/parts/fixture.jsx +60 -0
- package/templates/next/files/components/parts/index.js +5 -0
- package/templates/next/files/components/parts/record-switcher.jsx +49 -0
- package/templates/next/files/components/ui/button.jsx +81 -0
- package/templates/next/files/components/ui/cx.js +4 -0
- package/templates/next/files/components/ui/depiction.jsx +66 -0
- package/templates/next/files/components/ui/field.jsx +126 -0
- package/templates/next/files/components/ui/icon.jsx +87 -0
- package/templates/next/files/components/ui/index.js +12 -0
- package/templates/next/files/components/ui/menu.jsx +123 -0
- package/templates/next/files/components/ui/notice.jsx +86 -0
- package/templates/next/files/components/ui/sheet.jsx +89 -0
- package/templates/next/files/components/ui/tabs.jsx +81 -0
- package/templates/next/files/lib/actions.js +187 -0
- package/templates/next/files/lib/claw.js +219 -0
- package/templates/next/files/lib/data.js +171 -0
- package/templates/next/files/lib/db.js +72 -0
- package/templates/next/files/lib/hooks.js +218 -0
- package/templates/next/files/lib/platform.js +59 -0
- package/templates/next/files/lib/sample.js +12 -0
- package/templates/next/files/middleware.js +42 -0
- package/templates/next/files/next.config.mjs +41 -0
- package/templates/next/files/package.json +19 -0
- package/templates/next/files/postcss.config.mjs +3 -0
- package/templates/next/files/schema.sql +22 -0
- package/templates/next/files/surfaces/README.md +32 -0
- package/templates/next/files/surfaces.js +53 -0
- package/templates/next/template.json +118 -0
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# makersclaw
|
|
2
|
+
|
|
3
|
+
The Makersclaw app template, its kit and its blocks, as one command.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx makersclaw init my-app # lay the template
|
|
7
|
+
npx makersclaw search "kanban" # find a block by what it does
|
|
8
|
+
npx makersclaw add board # install it
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Every app Makersclaw builds starts from this template: Next.js 15 (app
|
|
12
|
+
router, JSX, server actions), Postgres, Tailwind v4 mapped to the
|
|
13
|
+
workspace's theme, the data layer and write actions, the `_claw` platform
|
|
14
|
+
client, and a kit of parts. The app designs its own screens on top.
|
|
15
|
+
|
|
16
|
+
## Components come with the template; blocks come when a surface needs one
|
|
17
|
+
|
|
18
|
+
- **Components** — the kit (`components/ui`: Button, Field, Select, Sheet,
|
|
19
|
+
Tabs, Menu, Notice, Icon…) and the parts bound to the data layer
|
|
20
|
+
(`components/parts`: RecordSwitcher, useFixture). `init` lays them into
|
|
21
|
+
every app. `add <component>` only restores one.
|
|
22
|
+
- **Blocks** — the bigger pieces a surface is assembled from: a board, a
|
|
23
|
+
job panel, and more to come. No app has one until `add` puts it in
|
|
24
|
+
`components/blocks/<name>/`. `search` finds them by what they do.
|
|
25
|
+
|
|
26
|
+
Everything this CLI lays is **fixed**: pinned by sha256 in
|
|
27
|
+
`check/pins.json`, and `npm run check` refuses a change. An app composes
|
|
28
|
+
the kit and the blocks through their props; what they cannot do is the
|
|
29
|
+
app's own component in `components/`. Fixes reach apps through `update`.
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
npx makersclaw init [dir] [-t next] lay the template: base files, the kit, the check
|
|
35
|
+
npx makersclaw add <name...> install blocks, or restore a kit component
|
|
36
|
+
npx makersclaw search <words...> find blocks and components by what they do
|
|
37
|
+
npx makersclaw view <name> one item: props, data, an example
|
|
38
|
+
npx makersclaw list [--type block] everything in this version
|
|
39
|
+
npx makersclaw remove <block...> uninstall blocks no file imports
|
|
40
|
+
npx makersclaw diff what update would change in this app
|
|
41
|
+
npx makersclaw update [--major] lay this version's fixed files and blocks
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`--cwd <dir>` names the app (default: the current folder); `--json` prints
|
|
45
|
+
one object instead of text.
|
|
46
|
+
|
|
47
|
+
An app records what it holds in two files: `makersclaw.json` (the template
|
|
48
|
+
and version, and each block with the version it came from) and
|
|
49
|
+
`check/pins.json` (the hash of every fixed file). `update` moves an app to
|
|
50
|
+
this version's fixed files and blocks and never touches its own files —
|
|
51
|
+
the shell, the surfaces, `components/*.jsx`. Within `0.x` a new minor is a
|
|
52
|
+
new contract: `update` refuses it until `--major`, because screens written
|
|
53
|
+
against the old one may need rebuilding.
|
|
54
|
+
|
|
55
|
+
## This repository
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
templates/next/template.json which files are fixed, docs, starters or merged; the kit's entries
|
|
59
|
+
templates/next/files/ the app — what `init` lays
|
|
60
|
+
registry/blocks/<name>/ block.json, the files `add` lays, README.md (what view/add print), demo.jsx
|
|
61
|
+
src/ the CLI (no dependencies)
|
|
62
|
+
test/ npm test · npm run check:regress · npm run test:app
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `npm test` — the CLI against scratch folders.
|
|
66
|
+
- `npm run check:regress` — the template's `npm run check`, case by case.
|
|
67
|
+
- `npm run test:app` — a real app: init, every block added with its demo as
|
|
68
|
+
a surface, `npm install`, `npm run check` (build, start, every surface).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx makersclaw — see src/cli.mjs.
|
|
3
|
+
import { main } from "../src/cli.mjs";
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
process.exitCode = main(process.argv.slice(2));
|
|
7
|
+
} catch (error) {
|
|
8
|
+
process.stderr.write(`makersclaw: ${error?.stack ?? error}\n`);
|
|
9
|
+
process.exitCode = 2;
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "makersclaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Makersclaw app template, its kit and its blocks: init an app, add the blocks a surface needs, search the registry.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"makersclaw": "bin/makersclaw.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"templates",
|
|
13
|
+
"registry",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.17"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test test/*.test.mjs",
|
|
21
|
+
"test:app": "node test/smoke-app.mjs",
|
|
22
|
+
"check:regress": "bash test/check-regress.sh"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"makersclaw",
|
|
26
|
+
"template",
|
|
27
|
+
"nextjs",
|
|
28
|
+
"blocks",
|
|
29
|
+
"components",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"homepage": "https://makersclaw.com",
|
|
33
|
+
"license": "UNLICENSED"
|
|
34
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Board
|
|
2
|
+
|
|
3
|
+
Records as cards in columns, one column per stage. Drag a card to another
|
|
4
|
+
column — with a mouse from anywhere on the card, on a phone from its grip,
|
|
5
|
+
from the keyboard through the card's menu ("Move to …") — and the stage is
|
|
6
|
+
saved. The move shows at once; when the write fails the card goes back and
|
|
7
|
+
the board says why.
|
|
8
|
+
|
|
9
|
+
```jsx
|
|
10
|
+
import { Board } from "../components/blocks/board"; // "./blocks/board" from components/
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It is a client component: render it from a `"use client"` component, and
|
|
14
|
+
load the rows in the server surface (README §4).
|
|
15
|
+
|
|
16
|
+
## The data
|
|
17
|
+
|
|
18
|
+
One table, one column that holds the stage (`groupBy`). Each row is a card.
|
|
19
|
+
`columns` is the stages in order — the values that column holds. A row
|
|
20
|
+
whose stage is not in `columns` lands in a last "No stage" column, so no
|
|
21
|
+
record goes missing; it can be dragged out of it, not into it.
|
|
22
|
+
|
|
23
|
+
## Props
|
|
24
|
+
|
|
25
|
+
- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the
|
|
26
|
+
example cards move on screen without saving (they are not rows).
|
|
27
|
+
- `groupBy` — the column that holds the stage, e.g. `"stage"`.
|
|
28
|
+
- `columns` — the stages in order: `["lead", "call", "won"]` (labels are
|
|
29
|
+
made from the keys) or `[{ key: "lead", label: "New lead" }, …]`.
|
|
30
|
+
- `source` — the table, e.g. `"public.deals"`: a move then saves itself
|
|
31
|
+
through `moveRow` (lib/actions.js — the activity row included).
|
|
32
|
+
- `onMove(row, to, from)` — instead of `source`, when a move is more than
|
|
33
|
+
one column: return `{ ok: true }` or `{ ok: false, error }`. Without
|
|
34
|
+
`source` or `onMove` the board only shows; nothing can be moved.
|
|
35
|
+
- `idColumn` — default `"id"`.
|
|
36
|
+
- `titleOf(row)` — the card's title, default `row.title ?? row.name`. It
|
|
37
|
+
also names the card in its menu, in announcements and in errors.
|
|
38
|
+
- `onOpen(row)` — the title becomes a button: open your `Sheet` to edit
|
|
39
|
+
the record. The block draws that button; you never style one.
|
|
40
|
+
- `renderDetails(row, { column })` — what the card says under its title:
|
|
41
|
+
the part of the board that is yours. Kit parts and tokens only.
|
|
42
|
+
- `metaOf(row)` — a plain muted line under the title, when that is all
|
|
43
|
+
the card needs (ignored when `renderDetails` is set).
|
|
44
|
+
- `actionsOf(row)` — more menu items for a card, kit `Menu` shape:
|
|
45
|
+
`[{ label, onSelect, icon?, tone?: "danger" }]`. The "Move to" items
|
|
46
|
+
follow them.
|
|
47
|
+
- `onCreate(title, column)` — an "Add" at every column's foot opens an
|
|
48
|
+
inline field; Enter calls this (insert the row with `insertRow`), the
|
|
49
|
+
field stays open for the next one. Return `{ ok: false, error }` to show
|
|
50
|
+
why it failed.
|
|
51
|
+
- `addLabel` · `createPlaceholder` · `emptyLabel` · `otherLabel` — words.
|
|
52
|
+
- `limits` — a work-in-progress limit per stage: `{ doing: 3 }` shows
|
|
53
|
+
`4/3` in the header, in the warning tone, when it is exceeded.
|
|
54
|
+
- `label` — what the board is called to a screen reader.
|
|
55
|
+
- `className` — layout around it (width, margin). Never colour.
|
|
56
|
+
|
|
57
|
+
## Example
|
|
58
|
+
|
|
59
|
+
```jsx
|
|
60
|
+
// surfaces/pipeline.jsx — server: load, hand over
|
|
61
|
+
import { loadRows } from "../lib/data";
|
|
62
|
+
import Pipeline from "../components/Pipeline";
|
|
63
|
+
|
|
64
|
+
export default async function PipelineSurface() {
|
|
65
|
+
const { rows, error } = await loadRows({ source: "public.deals", orderBy: "updated_at desc" });
|
|
66
|
+
return <Pipeline deals={rows} error={error} />;
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```jsx
|
|
71
|
+
// components/Pipeline.jsx — client: the board, the card, the edit sheet
|
|
72
|
+
"use client";
|
|
73
|
+
import { useState } from "react";
|
|
74
|
+
import { insertRow } from "../lib/actions";
|
|
75
|
+
import { Badge, Notice } from "./ui";
|
|
76
|
+
import { FixtureNotice, useFixture } from "./parts";
|
|
77
|
+
import { Board } from "./blocks/board";
|
|
78
|
+
|
|
79
|
+
const STAGES = [
|
|
80
|
+
{ key: "lead", label: "Lead" },
|
|
81
|
+
{ key: "call", label: "Call booked" },
|
|
82
|
+
{ key: "proposal", label: "Proposal" },
|
|
83
|
+
{ key: "won", label: "Won" },
|
|
84
|
+
];
|
|
85
|
+
const SAMPLE = [
|
|
86
|
+
{ name: "Northwind", stage: "call", value: 12000, owner: "Maya" },
|
|
87
|
+
{ name: "Acme Ltd", stage: "lead", value: 4000, owner: "Leon" },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export default function Pipeline({ deals, error }) {
|
|
91
|
+
const fixture = useFixture(deals, SAMPLE);
|
|
92
|
+
const [editing, setEditing] = useState(null);
|
|
93
|
+
return (
|
|
94
|
+
<div className="grid gap-4">
|
|
95
|
+
{error ? <Notice tone="danger">{error}</Notice> : null}
|
|
96
|
+
{fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}
|
|
97
|
+
<Board
|
|
98
|
+
rows={fixture.rows}
|
|
99
|
+
groupBy="stage"
|
|
100
|
+
columns={STAGES}
|
|
101
|
+
source="public.deals"
|
|
102
|
+
titleOf={(d) => d.name}
|
|
103
|
+
limits={{ proposal: 5 }}
|
|
104
|
+
onCreate={(name, stage) => insertRow({ source: "public.deals", values: { name, stage }, detail: name })}
|
|
105
|
+
onOpen={setEditing}
|
|
106
|
+
renderDetails={(d) => (
|
|
107
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
108
|
+
<span>{d.owner ?? "Unowned"}</span>
|
|
109
|
+
<Badge>{Number(d.value ?? 0).toLocaleString()}</Badge>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
label="Deals by stage"
|
|
113
|
+
/>
|
|
114
|
+
{/* <Sheet open={Boolean(editing)} …> the record's fields </Sheet> */}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## What it will not do
|
|
121
|
+
|
|
122
|
+
- Order cards inside a column: they keep the order of `rows` — sort in
|
|
123
|
+
the surface's `loadRows`.
|
|
124
|
+
- Create a stage: `columns` is the surface's; a new stage is a new entry.
|
|
125
|
+
- Colour a column: stages are not status. Put a `Badge` on the card when a
|
|
126
|
+
stage means something the founder must see.
|
|
127
|
+
|
|
128
|
+
A board it cannot be is your own component in `components/`, built from
|
|
129
|
+
the kit — not an edit to this block. Its files are fixed: `npm run check`
|
|
130
|
+
refuses a change, and `makersclaw add board` restores them.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "board",
|
|
3
|
+
"title": "Board",
|
|
4
|
+
"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.",
|
|
5
|
+
"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.",
|
|
6
|
+
"tags": ["board", "kanban", "pipeline", "stages", "columns", "cards", "drag", "drop", "status", "workflow", "funnel"],
|
|
7
|
+
"exports": ["Board"],
|
|
8
|
+
"files": ["index.js", "board.jsx"],
|
|
9
|
+
"requires": []
|
|
10
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { moveRow } from "../../../lib/actions";
|
|
6
|
+
import { isSample } from "../../parts";
|
|
7
|
+
import { Button, Icon, Input, Menu, Notice, cx } from "../../ui";
|
|
8
|
+
|
|
9
|
+
// Board — records as cards in columns, one column per stage, moved by
|
|
10
|
+
// dragging or from the card's menu. The block owns the behaviour a board
|
|
11
|
+
// gets wrong when it is drawn by hand: pointer dragging that works with a
|
|
12
|
+
// mouse and on a phone, a keyboard path (the menu), the move shown at
|
|
13
|
+
// once and put back with a reason when the write fails, examples that
|
|
14
|
+
// move on screen and save nothing, the count in every header, and cards
|
|
15
|
+
// no stage claims kept in view instead of lost. The surface owns what a
|
|
16
|
+
// card says under its title: renderDetails.
|
|
17
|
+
//
|
|
18
|
+
// <Board rows={deals} groupBy="stage" columns={STAGES} source="public.deals"
|
|
19
|
+
// titleOf={(d) => d.name} renderDetails={(d) => <DealFacts deal={d} />}
|
|
20
|
+
// onOpen={setEditing} />
|
|
21
|
+
//
|
|
22
|
+
// README.md beside this file lists every prop.
|
|
23
|
+
|
|
24
|
+
const OTHER = "__other";
|
|
25
|
+
const DRAG_START_PX = { mouse: 5, pen: 6, touch: 8 };
|
|
26
|
+
|
|
27
|
+
function humanize(key) {
|
|
28
|
+
const text = String(key ?? "").replace(/[_-]+/g, " ").trim();
|
|
29
|
+
return text ? text.charAt(0).toUpperCase() + text.slice(1) : "—";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeColumns(columns) {
|
|
33
|
+
return (columns ?? []).map((c) =>
|
|
34
|
+
typeof c === "string" || typeof c === "number"
|
|
35
|
+
? { key: String(c), label: humanize(c) }
|
|
36
|
+
: { ...c, key: String(c.key), label: c.label ?? humanize(c.key) },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function Board({
|
|
41
|
+
rows,
|
|
42
|
+
groupBy,
|
|
43
|
+
columns,
|
|
44
|
+
idColumn = "id",
|
|
45
|
+
source,
|
|
46
|
+
onMove,
|
|
47
|
+
renderDetails,
|
|
48
|
+
titleOf = (row) => row.title ?? row.name ?? String(row[idColumn] ?? ""),
|
|
49
|
+
metaOf,
|
|
50
|
+
onOpen,
|
|
51
|
+
actionsOf,
|
|
52
|
+
onCreate,
|
|
53
|
+
addLabel = "Add",
|
|
54
|
+
createPlaceholder = "Title, then Enter",
|
|
55
|
+
limits,
|
|
56
|
+
label = "Board",
|
|
57
|
+
emptyLabel = "Nothing here yet",
|
|
58
|
+
otherLabel = "No stage",
|
|
59
|
+
className,
|
|
60
|
+
}) {
|
|
61
|
+
const uid = useId();
|
|
62
|
+
const boardRef = useRef(null);
|
|
63
|
+
const dragRef = useRef(null);
|
|
64
|
+
const suppressClick = useRef(false);
|
|
65
|
+
const [overrides, setOverrides] = useState(() => new Map());
|
|
66
|
+
const [drag, setDrag] = useState(null);
|
|
67
|
+
const [error, setError] = useState(null);
|
|
68
|
+
const [announce, setAnnounce] = useState("");
|
|
69
|
+
const [adding, setAdding] = useState(null);
|
|
70
|
+
const [draft, setDraft] = useState("");
|
|
71
|
+
const [busy, setBusy] = useState(false);
|
|
72
|
+
const [portal, setPortal] = useState(null);
|
|
73
|
+
|
|
74
|
+
const movable = Boolean(onMove || source);
|
|
75
|
+
const list = rows ?? [];
|
|
76
|
+
const keyOf = (row) => String(row?.[idColumn]);
|
|
77
|
+
const cols = useMemo(() => normalizeColumns(columns), [columns]);
|
|
78
|
+
const known = useMemo(() => new Set(cols.map((c) => c.key)), [cols]);
|
|
79
|
+
const columnOf = (row) => {
|
|
80
|
+
const moved = overrides.get(keyOf(row));
|
|
81
|
+
const value = moved ?? (row?.[groupBy] == null ? "" : String(row[groupBy]));
|
|
82
|
+
return known.has(value) ? value : OTHER;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
useEffect(() => setPortal(document.body), []);
|
|
86
|
+
|
|
87
|
+
// A move stays on screen until the rows the page re-reads carry it; then
|
|
88
|
+
// the override has nothing left to say.
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
setOverrides((current) => {
|
|
91
|
+
if (current.size === 0) return current;
|
|
92
|
+
const next = new Map(current);
|
|
93
|
+
for (const [id, to] of current) {
|
|
94
|
+
const row = list.find((r) => keyOf(r) === id);
|
|
95
|
+
if (!row || String(row[groupBy]) === to) next.delete(id);
|
|
96
|
+
}
|
|
97
|
+
return next.size === current.size ? current : next;
|
|
98
|
+
});
|
|
99
|
+
// `list` changes identity with every read; that is the point.
|
|
100
|
+
}, [rows, groupBy]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
101
|
+
|
|
102
|
+
const byColumn = useMemo(() => {
|
|
103
|
+
const out = new Map(cols.map((c) => [c.key, []]));
|
|
104
|
+
out.set(OTHER, []);
|
|
105
|
+
for (const row of list) out.get(columnOf(row)).push(row);
|
|
106
|
+
return out;
|
|
107
|
+
}, [list, cols, overrides]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
108
|
+
|
|
109
|
+
const shown = byColumn.get(OTHER).length ? [...cols, { key: OTHER, label: otherLabel, other: true }] : cols;
|
|
110
|
+
const labelOf = (key) => shown.find((c) => c.key === key)?.label ?? humanize(key);
|
|
111
|
+
|
|
112
|
+
async function move(row, to) {
|
|
113
|
+
const from = columnOf(row);
|
|
114
|
+
if (!movable || to === from || to === OTHER || !known.has(to)) return;
|
|
115
|
+
const id = keyOf(row);
|
|
116
|
+
const title = titleOf(row);
|
|
117
|
+
setError(null);
|
|
118
|
+
setOverrides((m) => new Map(m).set(id, to));
|
|
119
|
+
setAnnounce(`${title} moved to ${labelOf(to)}`);
|
|
120
|
+
// An example moves on screen and saves nothing: it is not a row.
|
|
121
|
+
if (isSample(row)) return;
|
|
122
|
+
let result;
|
|
123
|
+
try {
|
|
124
|
+
result = onMove
|
|
125
|
+
? await onMove(row, to, from === OTHER ? null : from)
|
|
126
|
+
: await moveRow({ source, idColumn, id: row[idColumn], stateColumn: groupBy, to });
|
|
127
|
+
} catch (e) {
|
|
128
|
+
result = { ok: false, error: e?.message ?? String(e) };
|
|
129
|
+
}
|
|
130
|
+
if (result && result.ok === false) {
|
|
131
|
+
setOverrides((m) => {
|
|
132
|
+
const next = new Map(m);
|
|
133
|
+
next.delete(id);
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
setError(`“${title}” is back in ${labelOf(from)}: ${result.error ?? "the move was not saved"}`);
|
|
137
|
+
setAnnounce(`${title} could not be moved`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- pointer dragging ---------------------------------------------------
|
|
142
|
+
// A mouse drags a card from anywhere on it; touch and pen drag from the
|
|
143
|
+
// grip, so the column still scrolls under a finger.
|
|
144
|
+
|
|
145
|
+
function onPointerDown(e, row) {
|
|
146
|
+
if (!movable) return;
|
|
147
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
148
|
+
const onGrip = Boolean(e.target.closest?.("[data-board-grip]"));
|
|
149
|
+
if (e.pointerType !== "mouse" && !onGrip) return;
|
|
150
|
+
if (!onGrip && e.target.closest?.("input, textarea, select, a, [aria-haspopup], [role=menuitem]")) return;
|
|
151
|
+
// No text selection while a mouse drags; the click still lands.
|
|
152
|
+
if (e.pointerType === "mouse") e.preventDefault();
|
|
153
|
+
const card = e.currentTarget;
|
|
154
|
+
const rect = card.getBoundingClientRect();
|
|
155
|
+
dragRef.current = {
|
|
156
|
+
row,
|
|
157
|
+
id: keyOf(row),
|
|
158
|
+
from: columnOf(row),
|
|
159
|
+
pointerId: e.pointerId,
|
|
160
|
+
type: e.pointerType,
|
|
161
|
+
card,
|
|
162
|
+
startX: e.clientX,
|
|
163
|
+
startY: e.clientY,
|
|
164
|
+
offsetX: e.clientX - rect.left,
|
|
165
|
+
offsetY: e.clientY - rect.top,
|
|
166
|
+
width: rect.width,
|
|
167
|
+
active: false,
|
|
168
|
+
over: null,
|
|
169
|
+
x: e.clientX,
|
|
170
|
+
y: e.clientY,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function columnAt(x, y) {
|
|
175
|
+
const el = document.elementFromPoint(x, y);
|
|
176
|
+
const col = el?.closest?.("[data-board-column]");
|
|
177
|
+
return col && boardRef.current?.contains(col) ? col.getAttribute("data-board-column") : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function onPointerMove(e) {
|
|
181
|
+
const d = dragRef.current;
|
|
182
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
183
|
+
if (!d.active) {
|
|
184
|
+
const far = Math.hypot(e.clientX - d.startX, e.clientY - d.startY) >= (DRAG_START_PX[d.type] ?? 5);
|
|
185
|
+
if (!far) return;
|
|
186
|
+
d.active = true;
|
|
187
|
+
try {
|
|
188
|
+
d.card.setPointerCapture(e.pointerId);
|
|
189
|
+
} catch {
|
|
190
|
+
// the pointer is already gone; the move below still works
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
d.x = e.clientX;
|
|
195
|
+
d.y = e.clientY;
|
|
196
|
+
d.over = columnAt(e.clientX, e.clientY);
|
|
197
|
+
const board = boardRef.current;
|
|
198
|
+
if (board) {
|
|
199
|
+
const r = board.getBoundingClientRect();
|
|
200
|
+
if (e.clientX < r.left + 48) board.scrollBy({ left: -18 });
|
|
201
|
+
else if (e.clientX > r.right - 48) board.scrollBy({ left: 18 });
|
|
202
|
+
}
|
|
203
|
+
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 });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function finish(e, drop) {
|
|
207
|
+
const d = dragRef.current;
|
|
208
|
+
if (!d || (e && e.pointerId !== d.pointerId)) return;
|
|
209
|
+
dragRef.current = null;
|
|
210
|
+
if (!d.active) return;
|
|
211
|
+
suppressClick.current = true;
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
suppressClick.current = false;
|
|
214
|
+
}, 0);
|
|
215
|
+
setDrag(null);
|
|
216
|
+
if (drop && d.over) move(d.row, d.over);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!drag) return undefined;
|
|
221
|
+
const onKey = (e) => {
|
|
222
|
+
if (e.key === "Escape") finish(null, false);
|
|
223
|
+
};
|
|
224
|
+
window.addEventListener("keydown", onKey);
|
|
225
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
226
|
+
}, [drag]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
227
|
+
|
|
228
|
+
async function create(e, column) {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
const title = draft.trim();
|
|
231
|
+
if (!title || busy) return;
|
|
232
|
+
setBusy(true);
|
|
233
|
+
setError(null);
|
|
234
|
+
let result;
|
|
235
|
+
try {
|
|
236
|
+
result = await onCreate(title, column);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
result = { ok: false, error: err?.message ?? String(err) };
|
|
239
|
+
}
|
|
240
|
+
setBusy(false);
|
|
241
|
+
if (result && result.ok === false) setError(`“${title}” was not added: ${result.error ?? "the write failed"}`);
|
|
242
|
+
else {
|
|
243
|
+
setDraft("");
|
|
244
|
+
setAnnounce(`${title} added to ${labelOf(column)}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function card(row, { ghost = false } = {}) {
|
|
249
|
+
const title = titleOf(row);
|
|
250
|
+
const body = (
|
|
251
|
+
<>
|
|
252
|
+
{onOpen ? (
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
onClick={() => onOpen(row)}
|
|
256
|
+
className="block w-full rounded-sm text-left text-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
257
|
+
>
|
|
258
|
+
{title}
|
|
259
|
+
</button>
|
|
260
|
+
) : (
|
|
261
|
+
<p className="text-sm font-medium">{title}</p>
|
|
262
|
+
)}
|
|
263
|
+
{renderDetails ? (
|
|
264
|
+
<div className="mt-2">{renderDetails(row, { column: columnOf(row) })}</div>
|
|
265
|
+
) : metaOf ? (
|
|
266
|
+
<div className="mt-1 text-xs text-muted-foreground">{metaOf(row)}</div>
|
|
267
|
+
) : null}
|
|
268
|
+
</>
|
|
269
|
+
);
|
|
270
|
+
const moveTo = movable
|
|
271
|
+
? shown
|
|
272
|
+
.filter((c) => !c.other && c.key !== columnOf(row))
|
|
273
|
+
.map((c) => ({ label: `Move to ${c.label}`, icon: "arrow-right", onSelect: () => move(row, c.key) }))
|
|
274
|
+
: [];
|
|
275
|
+
const items = [...(actionsOf?.(row) ?? []), ...moveTo];
|
|
276
|
+
return (
|
|
277
|
+
<div className="flex items-start gap-1">
|
|
278
|
+
{movable && !ghost ? (
|
|
279
|
+
<span
|
|
280
|
+
data-board-grip
|
|
281
|
+
aria-hidden="true"
|
|
282
|
+
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"
|
|
283
|
+
style={{ touchAction: "none" }}
|
|
284
|
+
>
|
|
285
|
+
<Icon name="grip" size={16} />
|
|
286
|
+
</span>
|
|
287
|
+
) : null}
|
|
288
|
+
<div className="min-w-0 flex-1">{body}</div>
|
|
289
|
+
{!ghost ? <Menu items={items} label={`Actions for ${title}`} className="-mr-1 -mt-1 shrink-0" /> : null}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div className={cx("grid gap-3", className)}>
|
|
296
|
+
{error ? (
|
|
297
|
+
<Notice tone="danger" onDismiss={() => setError(null)}>
|
|
298
|
+
{error}
|
|
299
|
+
</Notice>
|
|
300
|
+
) : null}
|
|
301
|
+
<div
|
|
302
|
+
ref={boardRef}
|
|
303
|
+
role="region"
|
|
304
|
+
aria-label={label}
|
|
305
|
+
tabIndex={0}
|
|
306
|
+
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"
|
|
307
|
+
>
|
|
308
|
+
{shown.map((col) => {
|
|
309
|
+
const cards = byColumn.get(col.key) ?? [];
|
|
310
|
+
const limit = limits?.[col.key];
|
|
311
|
+
const over = typeof limit === "number" && cards.length > limit;
|
|
312
|
+
const target = drag && drag.over === col.key && !col.other && columnOf(drag.row) !== col.key;
|
|
313
|
+
const headingId = `${uid}-${col.key}`;
|
|
314
|
+
return (
|
|
315
|
+
<section
|
|
316
|
+
key={col.key}
|
|
317
|
+
data-board-column={col.other ? undefined : col.key}
|
|
318
|
+
aria-labelledby={headingId}
|
|
319
|
+
className={cx(
|
|
320
|
+
"flex max-h-[75dvh] w-[min(18rem,85vw)] shrink-0 snap-start flex-col rounded-lg border border-border bg-muted/50",
|
|
321
|
+
target && "bg-accent ring-2 ring-ring",
|
|
322
|
+
)}
|
|
323
|
+
>
|
|
324
|
+
<header className="flex min-h-11 items-center justify-between gap-2 px-3">
|
|
325
|
+
<h3 id={headingId} className="truncate text-sm font-semibold">
|
|
326
|
+
{col.label}
|
|
327
|
+
</h3>
|
|
328
|
+
<span
|
|
329
|
+
data-nums
|
|
330
|
+
className={cx("shrink-0 font-mono text-xs", over ? "font-semibold text-warning" : "text-muted-foreground")}
|
|
331
|
+
title={typeof limit === "number" ? `${cards.length} of a limit of ${limit}` : undefined}
|
|
332
|
+
>
|
|
333
|
+
{typeof limit === "number" ? `${cards.length}/${limit}` : cards.length}
|
|
334
|
+
</span>
|
|
335
|
+
</header>
|
|
336
|
+
<ul className="flex min-h-16 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
|
|
337
|
+
{cards.map((row) => {
|
|
338
|
+
const id = keyOf(row);
|
|
339
|
+
const lifted = drag?.id === id;
|
|
340
|
+
return (
|
|
341
|
+
<li
|
|
342
|
+
key={id}
|
|
343
|
+
data-board-card={id}
|
|
344
|
+
onPointerDown={(e) => onPointerDown(e, row)}
|
|
345
|
+
onPointerMove={onPointerMove}
|
|
346
|
+
onPointerUp={(e) => finish(e, true)}
|
|
347
|
+
onPointerCancel={(e) => finish(e, false)}
|
|
348
|
+
onClickCapture={(e) => {
|
|
349
|
+
if (suppressClick.current) {
|
|
350
|
+
e.preventDefault();
|
|
351
|
+
e.stopPropagation();
|
|
352
|
+
}
|
|
353
|
+
}}
|
|
354
|
+
className={cx(
|
|
355
|
+
"rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm",
|
|
356
|
+
movable && "sm:cursor-grab",
|
|
357
|
+
lifted && "opacity-40",
|
|
358
|
+
)}
|
|
359
|
+
>
|
|
360
|
+
{card(row)}
|
|
361
|
+
</li>
|
|
362
|
+
);
|
|
363
|
+
})}
|
|
364
|
+
{cards.length === 0 ? (
|
|
365
|
+
<li className="rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">{emptyLabel}</li>
|
|
366
|
+
) : null}
|
|
367
|
+
</ul>
|
|
368
|
+
{onCreate && !col.other ? (
|
|
369
|
+
adding === col.key ? (
|
|
370
|
+
<form onSubmit={(e) => create(e, col.key)} className="px-2 pb-2">
|
|
371
|
+
<label htmlFor={`${headingId}-new`} className="sr-only">
|
|
372
|
+
New in {col.label}
|
|
373
|
+
</label>
|
|
374
|
+
<Input
|
|
375
|
+
id={`${headingId}-new`}
|
|
376
|
+
autoFocus
|
|
377
|
+
value={draft}
|
|
378
|
+
disabled={busy}
|
|
379
|
+
placeholder={createPlaceholder}
|
|
380
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
381
|
+
onKeyDown={(e) => {
|
|
382
|
+
// Enter adds here, not through the form's implicit
|
|
383
|
+
// submit: a synthetic Enter (an agent testing the
|
|
384
|
+
// app) has no keypress to submit with. Never while
|
|
385
|
+
// an input method is composing a word.
|
|
386
|
+
if (e.key === "Enter" && !e.nativeEvent.isComposing) create(e, col.key);
|
|
387
|
+
else if (e.key === "Escape") {
|
|
388
|
+
setAdding(null);
|
|
389
|
+
setDraft("");
|
|
390
|
+
}
|
|
391
|
+
}}
|
|
392
|
+
onBlur={() => {
|
|
393
|
+
if (!draft.trim()) setAdding(null);
|
|
394
|
+
}}
|
|
395
|
+
/>
|
|
396
|
+
</form>
|
|
397
|
+
) : (
|
|
398
|
+
<div className="px-2 pb-2">
|
|
399
|
+
<Button
|
|
400
|
+
variant="quiet"
|
|
401
|
+
size="sm"
|
|
402
|
+
icon="plus"
|
|
403
|
+
className="w-full justify-start"
|
|
404
|
+
onClick={() => {
|
|
405
|
+
setAdding(col.key);
|
|
406
|
+
setDraft("");
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
{addLabel}
|
|
410
|
+
</Button>
|
|
411
|
+
</div>
|
|
412
|
+
)
|
|
413
|
+
) : null}
|
|
414
|
+
</section>
|
|
415
|
+
);
|
|
416
|
+
})}
|
|
417
|
+
</div>
|
|
418
|
+
<p aria-live="polite" className="sr-only">
|
|
419
|
+
{announce}
|
|
420
|
+
</p>
|
|
421
|
+
{drag && portal
|
|
422
|
+
? createPortal(
|
|
423
|
+
<div
|
|
424
|
+
aria-hidden="true"
|
|
425
|
+
className="pointer-events-none fixed z-50 rotate-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg"
|
|
426
|
+
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}
|
|
427
|
+
>
|
|
428
|
+
{card(drag.row, { ghost: true })}
|
|
429
|
+
</div>,
|
|
430
|
+
portal,
|
|
431
|
+
)
|
|
432
|
+
: null}
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
}
|