proto-comments 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +157 -2
- package/bin/proto-comments.js +89 -0
- package/dist/index.js +649 -0
- package/package.json +29 -4
- package/server/next.js +25 -0
- package/server/storage.js +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 proto-comments contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,158 @@
|
|
|
1
|
-
#
|
|
1
|
+
# proto-comments
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Figma-style review comments on a running React prototype.
|
|
4
|
+
|
|
5
|
+
- **Comments are split by screen, not only by URL.** Prototypes often show every screen on one URL. Tell the plugin what a "screen" is, and a comment left in Settings won't show up on the deal page.
|
|
6
|
+
- **Pinned to elements.** A pin follows its element when the window is resized or scrolled. Each comment records the block it's in, for example "Review before I send these invites › Emails".
|
|
7
|
+
- **History.** All comments, grouped by screen. Click one to reopen its screen in the saved state, scroll to the element and open the thread.
|
|
8
|
+
- **Made for a coding agent.** Comments live in one JSON file in your project. Mark the ones you want, tell the agent "apply the comments", and it resolves each with a reply saying what changed.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -D proto-comments
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
React 18+. Mount it once, in development only.
|
|
17
|
+
|
|
18
|
+
### Next.js (App Router)
|
|
19
|
+
|
|
20
|
+
Store comments through an API route:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
// app/api/comments/route.js
|
|
24
|
+
import { createNextHandlers } from 'proto-comments/next';
|
|
25
|
+
|
|
26
|
+
export const dynamic = 'force-dynamic';
|
|
27
|
+
export const { GET, POST, PATCH, DELETE } = createNextHandlers(); // off outside `next dev`
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```jsx
|
|
31
|
+
// app/layout.js
|
|
32
|
+
import { ProtoComments } from 'proto-comments';
|
|
33
|
+
|
|
34
|
+
export default function RootLayout({ children }) {
|
|
35
|
+
return (
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<body>
|
|
38
|
+
{children}
|
|
39
|
+
{process.env.NODE_ENV === 'development' && <ProtoComments author="Anna" />}
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Vite and other apps
|
|
47
|
+
|
|
48
|
+
Run the bundled local server next to your dev server:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx proto-comments serve # http://localhost:4848/api/comments
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```jsx
|
|
55
|
+
{import.meta.env.DEV && <ProtoComments endpoint="http://localhost:4848/api/comments" author="Anna" />}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The server only accepts requests from `localhost` pages.
|
|
59
|
+
|
|
60
|
+
Either way, comments are saved to `comments/comments.json`, relative to the folder you run the dev server from. Use `createNextHandlers({ file })` or `serve --file` to change it.
|
|
61
|
+
|
|
62
|
+
## Use
|
|
63
|
+
|
|
64
|
+
| | |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| **C** | Start commenting: hover outlines an element, click it to leave a comment |
|
|
67
|
+
| **V** | Stop commenting |
|
|
68
|
+
| **Esc** | Close the open comment or History |
|
|
69
|
+
| Toolbar | Bottom center. Drag it by the dots; it stays where you put it |
|
|
70
|
+
|
|
71
|
+
The shortcuts use physical keys, so they work in any keyboard layout. In text fields C types a letter.
|
|
72
|
+
|
|
73
|
+
In a comment you can reply, resolve, mark it for the agent, or delete it. In History you can filter (Open, For Agent, Resolved, All), select several comments and **Mark for Agent**, or delete everything.
|
|
74
|
+
|
|
75
|
+
## Screens in single-URL prototypes
|
|
76
|
+
|
|
77
|
+
By default a screen is `location.pathname + location.hash`, and History reopens a comment by going to its URL. If your prototype switches screens in state, pass three functions:
|
|
78
|
+
|
|
79
|
+
```jsx
|
|
80
|
+
'use client';
|
|
81
|
+
import { ProtoComments } from 'proto-comments';
|
|
82
|
+
import { useApp } from '@/lib/store'; // your store
|
|
83
|
+
|
|
84
|
+
export default function Review() {
|
|
85
|
+
return (
|
|
86
|
+
<ProtoComments
|
|
87
|
+
author="Anna"
|
|
88
|
+
getScreen={() => {
|
|
89
|
+
const s = useApp.getState();
|
|
90
|
+
return { key: s.view === 'settings' ? `settings:${s.tab}` : s.view, label: s.view };
|
|
91
|
+
}}
|
|
92
|
+
getSnapshot={() => structuredClone(useApp.getState())} // must be JSON-serializable
|
|
93
|
+
restoreSnapshot={snap => useApp.setState(snap)} // reopen that exact state
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`getScreen` is called on every animation frame, so keep it cheap.
|
|
100
|
+
|
|
101
|
+
### All props
|
|
102
|
+
|
|
103
|
+
| Prop | Default | |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `endpoint` | `'/api/comments'` | Where comments are stored |
|
|
106
|
+
| `author` | `'Reviewer'` | Name on new comments and replies |
|
|
107
|
+
| `getScreen` | URL | `() => ({ key, label })`. Comments show only on a screen with the same `key`; `label` groups History |
|
|
108
|
+
| `getSnapshot` | `{ url }` | JSON saved with each comment |
|
|
109
|
+
| `restoreSnapshot` | go to `url` | `(snapshot, comment) => void \| Promise`. Called from History when the comment is on another screen |
|
|
110
|
+
| `getContext` | none | `() => string` shown in History, e.g. `"new client · admin"` |
|
|
111
|
+
| `headingSelector` | none | Extra selectors for block titles, e.g. `'.card-title'`. Built-in: `h1`–`h6`, `legend`, `[class*="title"]`, `label` |
|
|
112
|
+
|
|
113
|
+
**Better anchors.** A pin is found by a CSS path from the nearest ancestor with an `id` or `data-cid`, plus the element's text. Add `data-cid="…"` to key containers if your markup has few ids.
|
|
114
|
+
|
|
115
|
+
**Theming.** Colors are CSS variables on `.pc-root` (`--pc-primary`, `--pc-dark`, `--pc-agent`, …). Override them in your own CSS.
|
|
116
|
+
|
|
117
|
+
## Working with a coding agent
|
|
118
|
+
|
|
119
|
+
The agent reads and resolves comments in the JSON file directly, or through the CLI:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npx proto-comments list # marked "For Agent" if any, otherwise all open
|
|
123
|
+
npx proto-comments list --all # also --open, --queued
|
|
124
|
+
npx proto-comments show 3 # selector, block, screen, replies
|
|
125
|
+
npx proto-comments resolve 3 "Shortened the subtitle to one sentence"
|
|
126
|
+
npx proto-comments reply 4 "Should this apply to the popup too?"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Options: `--file <path>`, `--author <name>` (default `Agent`).
|
|
130
|
+
|
|
131
|
+
The page doesn't poll. It reloads comments when its tab becomes active again, so the agent's replies show up when you come back.
|
|
132
|
+
|
|
133
|
+
Add this to your `CLAUDE.md` or `AGENTS.md`:
|
|
134
|
+
|
|
135
|
+
```md
|
|
136
|
+
## Review comments
|
|
137
|
+
Comments on the prototype live in `comments/comments.json` (proto-comments).
|
|
138
|
+
- "apply the comments": apply the ones marked "For Agent" (`queued: true`); if none are marked, every open comment. "apply 2 and 5": only those numbers.
|
|
139
|
+
- `npx proto-comments list` shows that set; `show <n>` gives the selector, block and screen to find the component.
|
|
140
|
+
- After a change: `npx proto-comments resolve <n> "what changed"`. If a comment is unclear, don't change code: `reply <n> "question"` and leave it open.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Comment format
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"id": "c1a2b3", "n": 3, "status": "open", "queued": true,
|
|
148
|
+
"screen": "settings:preferences", "screenLabel": "Settings · Preferences", "context": "",
|
|
149
|
+
"anchor": { "selector": "#main > div.card > h3", "block": "Homepage", "text": "Default page", "tag": "h3", "offset": { "x": 0.4, "y": 0.5 } },
|
|
150
|
+
"text": "Rename to Start page", "author": "Anna", "createdAt": "2026-10-22T10:00:00.000Z",
|
|
151
|
+
"replies": [{ "author": "Agent", "text": "Renamed", "createdAt": "…" }],
|
|
152
|
+
"snapshot": {}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { createStorage, DEFAULT_FILE } from '../server/storage.js';
|
|
4
|
+
|
|
5
|
+
const HELP = `proto-comments — review comments on a prototype
|
|
6
|
+
|
|
7
|
+
proto-comments serve [--port 4848] local server for apps without their own API route (Vite etc.)
|
|
8
|
+
proto-comments list [--queued|--open|--all] default: what "apply the comments" covers
|
|
9
|
+
proto-comments show <n> one comment with its anchor and replies
|
|
10
|
+
proto-comments resolve <n> "what changed" close it with a reply
|
|
11
|
+
proto-comments reply <n> "question" reply and keep it open
|
|
12
|
+
|
|
13
|
+
Options: --file <path> (default ${DEFAULT_FILE}), --author <name> (default Agent)`;
|
|
14
|
+
|
|
15
|
+
function parse(argv) {
|
|
16
|
+
const opts = { _: [] };
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const a = argv[i];
|
|
19
|
+
if (a.startsWith('--')) {
|
|
20
|
+
const key = a.slice(2);
|
|
21
|
+
const next = argv[i + 1];
|
|
22
|
+
if (['file', 'port', 'author'].includes(key)) { opts[key] = next; i++; } else opts[key] = true;
|
|
23
|
+
} else opts._.push(a);
|
|
24
|
+
}
|
|
25
|
+
return opts;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const place = a => [...new Set([a.block, a.text].filter(Boolean))].join(' › ') || `<${a.tag}>`;
|
|
29
|
+
const line = c => `#${c.n} ${c.status}${c.queued ? ' [For Agent]' : ''} · ${c.screenLabel} · ${place(c.anchor)}\n ${c.text}`;
|
|
30
|
+
/** Marked "For Agent" if any are marked, otherwise every open comment. */
|
|
31
|
+
const todo = list => { const open = list.filter(c => c.status === 'open'); const q = open.filter(c => c.queued); return q.length ? q : open; };
|
|
32
|
+
|
|
33
|
+
function serve(storage, port) {
|
|
34
|
+
const server = http.createServer(async (req, res) => {
|
|
35
|
+
const origin = req.headers.origin;
|
|
36
|
+
/* Local prototypes only: a page on another site must not be able to write comments, even with a "simple" request. */
|
|
37
|
+
const local = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(origin || '');
|
|
38
|
+
if (origin && !local) return res.writeHead(403).end();
|
|
39
|
+
if (local) {
|
|
40
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
41
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
|
|
42
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type');
|
|
43
|
+
}
|
|
44
|
+
if (req.method === 'OPTIONS') return res.writeHead(204).end();
|
|
45
|
+
if (req.method !== 'GET' && !String(req.headers['content-type']).startsWith('application/json')) return res.writeHead(415).end();
|
|
46
|
+
if (!req.url.startsWith('/api/comments')) return res.writeHead(404).end();
|
|
47
|
+
let raw = '';
|
|
48
|
+
for await (const chunk of req) raw += chunk;
|
|
49
|
+
try {
|
|
50
|
+
const { status, json } = await storage.handle(req.method, raw ? JSON.parse(raw) : null);
|
|
51
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(json));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: e.message }));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
server.listen(port, '127.0.0.1', () => {
|
|
57
|
+
console.log(`proto-comments: http://localhost:${port}/api/comments → ${storage.file}`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function main() {
|
|
62
|
+
const opts = parse(process.argv.slice(2));
|
|
63
|
+
const [cmd, n, ...words] = opts._;
|
|
64
|
+
const storage = createStorage(opts.file);
|
|
65
|
+
const find = list => list.find(c => c.n === Number(n)) || (console.error(`No comment #${n}`), process.exit(1));
|
|
66
|
+
|
|
67
|
+
if (cmd === 'serve') return serve(storage, Number(opts.port) || 4848);
|
|
68
|
+
const list = await storage.read();
|
|
69
|
+
if (cmd === 'list') {
|
|
70
|
+
const shown = opts.queued ? list.filter(c => c.queued && c.status === 'open')
|
|
71
|
+
: opts.open ? list.filter(c => c.status === 'open') : opts.all ? list : todo(list);
|
|
72
|
+
return console.log(shown.map(line).join('\n') || 'No comments.');
|
|
73
|
+
}
|
|
74
|
+
if (cmd === 'show') {
|
|
75
|
+
const c = find(list);
|
|
76
|
+
console.log(`${line(c)}\n screen: ${c.screen}${c.context ? ` (${c.context})` : ''}\n selector: ${c.anchor.selector}`);
|
|
77
|
+
return c.replies.forEach(r => console.log(` ↳ ${r.author}: ${r.text}`));
|
|
78
|
+
}
|
|
79
|
+
if (cmd === 'resolve' || cmd === 'reply') {
|
|
80
|
+
const c = find(list);
|
|
81
|
+
c.replies.push({ author: opts.author || 'Agent', text: words.join(' ') || 'Done.', createdAt: new Date().toISOString() });
|
|
82
|
+
if (cmd === 'resolve') { c.status = 'resolved'; c.queued = false; }
|
|
83
|
+
await storage.write(list);
|
|
84
|
+
return console.log(line(c));
|
|
85
|
+
}
|
|
86
|
+
console.log(HELP);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/index.jsx
|
|
4
|
+
import { useEffect as useEffect2, useState as useState2 } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
|
|
7
|
+
// src/store.js
|
|
8
|
+
import { useSyncExternalStore } from "react";
|
|
9
|
+
|
|
10
|
+
// src/anchors.js
|
|
11
|
+
var ROOT_SEL = (el) => el.id ? `#${CSS.escape(el.id)}` : el.dataset?.cid ? `[data-cid="${el.dataset.cid}"]` : null;
|
|
12
|
+
var textOf = (el) => (el.innerText || el.getAttribute?.("aria-label") || "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
13
|
+
function selectorFor(el) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
16
|
+
const root = ROOT_SEL(n);
|
|
17
|
+
if (root) {
|
|
18
|
+
parts.unshift(root);
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
const tag = n.tagName.toLowerCase();
|
|
22
|
+
const cls = [...n.classList].find((c) => !c.startsWith("pc-"));
|
|
23
|
+
const same = n.parentElement ? [...n.parentElement.children].filter((c) => c.tagName === n.tagName) : [];
|
|
24
|
+
parts.unshift(`${tag}${cls ? "." + CSS.escape(cls) : ""}${same.length > 1 ? `:nth-of-type(${same.indexOf(n) + 1})` : ""}`);
|
|
25
|
+
}
|
|
26
|
+
return parts.join(" > ");
|
|
27
|
+
}
|
|
28
|
+
var HEADING = 'h1, h2, h3, h4, h5, h6, legend, [class*="title"], label';
|
|
29
|
+
function blockFor(el, extra = "") {
|
|
30
|
+
const sel = extra ? `${extra}, ${HEADING}` : HEADING;
|
|
31
|
+
if (el.matches(sel) || el.closest(extra ? `${extra}, h1, h2, h3, h4, h5, h6, legend` : "h1, h2, h3, h4, h5, h6, legend")) return "";
|
|
32
|
+
const own = textOf(el);
|
|
33
|
+
const before = (h) => h.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING;
|
|
34
|
+
for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
|
|
35
|
+
const h = [...n.querySelectorAll(sel)].find((h2) => before(h2) && !h2.closest(".pc-root") && textOf(h2) && textOf(h2) !== own);
|
|
36
|
+
if (h) return textOf(h);
|
|
37
|
+
if (n.tagName === "MAIN") break;
|
|
38
|
+
}
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
var placeOf = (a) => [...new Set([a.block, a.text].filter(Boolean))].join(" \u203A ") || `<${a.tag}>`;
|
|
42
|
+
function anchorFor(el, x, y, headingSelector) {
|
|
43
|
+
const r = el.getBoundingClientRect();
|
|
44
|
+
return {
|
|
45
|
+
selector: selectorFor(el),
|
|
46
|
+
text: textOf(el),
|
|
47
|
+
block: blockFor(el, headingSelector),
|
|
48
|
+
tag: el.tagName.toLowerCase(),
|
|
49
|
+
offset: { x: r.width ? (x - r.left) / r.width : 0, y: r.height ? (y - r.top) / r.height : 0 }
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function findAnchor(a) {
|
|
53
|
+
let el = null;
|
|
54
|
+
try {
|
|
55
|
+
el = document.querySelector(a.selector);
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
if (el && (!a.text || textOf(el) === a.text)) return el;
|
|
59
|
+
if (a.text) {
|
|
60
|
+
const match = [...document.querySelectorAll(a.tag)].find((n) => textOf(n) === a.text && !n.closest(".pc-root"));
|
|
61
|
+
if (match) return match;
|
|
62
|
+
}
|
|
63
|
+
return el;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/store.js
|
|
67
|
+
var state = { list: [], mode: false, historyOpen: false, openId: null, draft: null, flashId: null, filter: "open" };
|
|
68
|
+
var subs = /* @__PURE__ */ new Set();
|
|
69
|
+
var getC = () => state;
|
|
70
|
+
var setC = (patch) => {
|
|
71
|
+
state = { ...state, ...patch };
|
|
72
|
+
subs.forEach((f) => f());
|
|
73
|
+
};
|
|
74
|
+
var subscribe = (f) => {
|
|
75
|
+
subs.add(f);
|
|
76
|
+
return () => subs.delete(f);
|
|
77
|
+
};
|
|
78
|
+
var useC = (sel) => useSyncExternalStore(subscribe, () => sel(state), () => sel(state));
|
|
79
|
+
var GO_KEY = "proto-comments:go";
|
|
80
|
+
var DEFAULTS = {
|
|
81
|
+
endpoint: "/api/comments",
|
|
82
|
+
author: "Reviewer",
|
|
83
|
+
getScreen: () => ({ key: location.pathname + location.hash, label: location.pathname + location.hash }),
|
|
84
|
+
getSnapshot: () => ({ url: location.href }),
|
|
85
|
+
restoreSnapshot: (snap) => {
|
|
86
|
+
if (!snap?.url || snap.url === location.href) return;
|
|
87
|
+
try {
|
|
88
|
+
sessionStorage.setItem(GO_KEY, pendingId);
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
location.assign(snap.url);
|
|
92
|
+
return "navigating";
|
|
93
|
+
},
|
|
94
|
+
getContext: () => "",
|
|
95
|
+
headingSelector: ""
|
|
96
|
+
};
|
|
97
|
+
var cfg = DEFAULTS;
|
|
98
|
+
var pendingId = null;
|
|
99
|
+
var setConfig = (c) => {
|
|
100
|
+
cfg = c;
|
|
101
|
+
};
|
|
102
|
+
var getConfig = () => cfg;
|
|
103
|
+
async function api(method, body) {
|
|
104
|
+
const res = await fetch(cfg.endpoint, { method, headers: { "content-type": "application/json" }, body: body && JSON.stringify(body) });
|
|
105
|
+
if (!res.ok) throw new Error(`proto-comments: ${method} ${cfg.endpoint} \u2192 ${res.status}`);
|
|
106
|
+
return res.json();
|
|
107
|
+
}
|
|
108
|
+
async function loadComments() {
|
|
109
|
+
const list = await api("GET");
|
|
110
|
+
if (JSON.stringify(list) !== JSON.stringify(state.list)) setC({ list });
|
|
111
|
+
}
|
|
112
|
+
async function addComment(anchor, text) {
|
|
113
|
+
const screen = cfg.getScreen();
|
|
114
|
+
const c = await api("POST", { screen: screen.key, screenLabel: screen.label, context: cfg.getContext(), snapshot: cfg.getSnapshot(), anchor, text, author: cfg.author });
|
|
115
|
+
setC({ list: [...state.list, c], draft: null });
|
|
116
|
+
}
|
|
117
|
+
async function patchComment(id, patch) {
|
|
118
|
+
const c = await api("PATCH", { id, ...patch });
|
|
119
|
+
setC({ list: state.list.map((x) => x.id === id ? c : x) });
|
|
120
|
+
}
|
|
121
|
+
async function markComments(ids, queued) {
|
|
122
|
+
setC({ list: await api("PATCH", { ids, queued }) });
|
|
123
|
+
}
|
|
124
|
+
async function deleteComment(id) {
|
|
125
|
+
await api("DELETE", { id });
|
|
126
|
+
setC({ list: state.list.filter((x) => x.id !== id), openId: null });
|
|
127
|
+
}
|
|
128
|
+
async function deleteAllComments() {
|
|
129
|
+
await api("DELETE", { all: true });
|
|
130
|
+
setC({ list: [], openId: null, draft: null });
|
|
131
|
+
}
|
|
132
|
+
function reveal(c) {
|
|
133
|
+
findAnchor(c.anchor)?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
134
|
+
setC({ openId: c.id, flashId: c.id });
|
|
135
|
+
setTimeout(() => setC({ flashId: null }), 1600);
|
|
136
|
+
}
|
|
137
|
+
async function goToComment(c) {
|
|
138
|
+
const onScreen = cfg.getScreen().key === c.screen && findAnchor(c.anchor);
|
|
139
|
+
setC({ openId: null, historyOpen: false });
|
|
140
|
+
if (onScreen) return reveal(c);
|
|
141
|
+
pendingId = c.id;
|
|
142
|
+
if (await cfg.restoreSnapshot(c.snapshot, c) === "navigating") return;
|
|
143
|
+
setTimeout(() => reveal(c), 450);
|
|
144
|
+
}
|
|
145
|
+
function resumePendingGo() {
|
|
146
|
+
let id = null;
|
|
147
|
+
try {
|
|
148
|
+
id = sessionStorage.getItem(GO_KEY);
|
|
149
|
+
sessionStorage.removeItem(GO_KEY);
|
|
150
|
+
} catch {
|
|
151
|
+
}
|
|
152
|
+
const c = id && state.list.find((x) => x.id === id);
|
|
153
|
+
if (c) setTimeout(() => reveal(c), 450);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/ui.jsx
|
|
157
|
+
import { useEffect, useRef, useState } from "react";
|
|
158
|
+
|
|
159
|
+
// src/icons.jsx
|
|
160
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
161
|
+
var PATHS = {
|
|
162
|
+
comment: /* @__PURE__ */ jsx("path", { d: "M3 4.5A1.5 1.5 0 0 1 4.5 3h7A1.5 1.5 0 0 1 13 4.5v5a1.5 1.5 0 0 1-1.5 1.5H7l-3 2.5V11h0a1 1 0 0 1-1-1z" }),
|
|
163
|
+
list: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
164
|
+
/* @__PURE__ */ jsx("path", { d: "M6 4.5h7M6 8h7M6 11.5h7" }),
|
|
165
|
+
/* @__PURE__ */ jsx("path", { d: "M3 4.5h.01M3 8h.01M3 11.5h.01" })
|
|
166
|
+
] }),
|
|
167
|
+
x: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" }),
|
|
168
|
+
trash: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
169
|
+
/* @__PURE__ */ jsx("path", { d: "M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.5 8.5h6l.5-8.5" }),
|
|
170
|
+
/* @__PURE__ */ jsx("path", { d: "M7 7v3.5M9 7v3.5" })
|
|
171
|
+
] })
|
|
172
|
+
};
|
|
173
|
+
function Icon({ name }) {
|
|
174
|
+
return /* @__PURE__ */ jsx("svg", { className: "pc-i", viewBox: "0 0 16 16", width: "16", height: "16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: PATHS[name] });
|
|
175
|
+
}
|
|
176
|
+
function Grip() {
|
|
177
|
+
return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 16 16", width: "16", height: "16", fill: "currentColor", "aria-hidden": "true", children: [4, 8, 12].map((y) => [6, 10].map((x) => /* @__PURE__ */ jsx("circle", { cx: x, cy: y, r: "1.3" }, `${x}${y}`))) });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/ui.jsx
|
|
181
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
182
|
+
var when = (iso) => new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
183
|
+
var popStyle = (x, y) => ({
|
|
184
|
+
left: Math.max(8, Math.min(x + 18, window.innerWidth - 328)),
|
|
185
|
+
top: Math.max(8, Math.min(y - 8, window.innerHeight - 260))
|
|
186
|
+
});
|
|
187
|
+
function Field({ placeholder, submit, onCancel, label }) {
|
|
188
|
+
const [text, setText] = useState("");
|
|
189
|
+
const [busy, setBusy] = useState(false);
|
|
190
|
+
const go = async () => {
|
|
191
|
+
if (!text.trim() || busy) return;
|
|
192
|
+
setBusy(true);
|
|
193
|
+
try {
|
|
194
|
+
await submit(text.trim());
|
|
195
|
+
setText("");
|
|
196
|
+
} finally {
|
|
197
|
+
setBusy(false);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
201
|
+
/* @__PURE__ */ jsx2(
|
|
202
|
+
"textarea",
|
|
203
|
+
{
|
|
204
|
+
className: "pc-input",
|
|
205
|
+
autoFocus: true,
|
|
206
|
+
rows: 3,
|
|
207
|
+
placeholder,
|
|
208
|
+
value: text,
|
|
209
|
+
onChange: (e) => setText(e.target.value),
|
|
210
|
+
onKeyDown: (e) => {
|
|
211
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
go();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
),
|
|
218
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-actions", children: [
|
|
219
|
+
onCancel && /* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: onCancel, children: "Cancel" }),
|
|
220
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-btn primary", disabled: !text.trim() || busy, onClick: go, children: label })
|
|
221
|
+
] })
|
|
222
|
+
] });
|
|
223
|
+
}
|
|
224
|
+
function Draft({ draft }) {
|
|
225
|
+
return /* @__PURE__ */ jsxs2("div", { className: "pc-pop", style: popStyle(draft.x, draft.y), children: [
|
|
226
|
+
/* @__PURE__ */ jsx2("div", { className: "pc-target", title: placeOf(draft.anchor), children: placeOf(draft.anchor) }),
|
|
227
|
+
/* @__PURE__ */ jsx2(Field, { placeholder: "Leave a comment", label: "Comment", submit: (text) => addComment(draft.anchor, text), onCancel: () => setC({ draft: null }) })
|
|
228
|
+
] });
|
|
229
|
+
}
|
|
230
|
+
function Thread({ c, x, y, lost }) {
|
|
231
|
+
const resolved = c.status === "resolved";
|
|
232
|
+
return /* @__PURE__ */ jsxs2("div", { className: "pc-pop", style: popStyle(x, y), children: [
|
|
233
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-head", children: [
|
|
234
|
+
/* @__PURE__ */ jsxs2("b", { children: [
|
|
235
|
+
"#",
|
|
236
|
+
c.n
|
|
237
|
+
] }),
|
|
238
|
+
/* @__PURE__ */ jsx2("span", { children: c.screenLabel }),
|
|
239
|
+
c.queued && /* @__PURE__ */ jsx2("i", { className: "pc-tag", children: "For Agent" }),
|
|
240
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => setC({ openId: null }), "aria-label": "Close", title: "Close", children: /* @__PURE__ */ jsx2(Icon, { name: "x" }) })
|
|
241
|
+
] }),
|
|
242
|
+
/* @__PURE__ */ jsx2("div", { className: "pc-target", title: placeOf(c.anchor), children: placeOf(c.anchor) }),
|
|
243
|
+
lost && /* @__PURE__ */ jsx2("div", { className: "pc-lost", children: "The element isn't on this screen any more." }),
|
|
244
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-msg", children: [
|
|
245
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-meta", children: [
|
|
246
|
+
c.author,
|
|
247
|
+
" \xB7 ",
|
|
248
|
+
when(c.createdAt)
|
|
249
|
+
] }),
|
|
250
|
+
c.text
|
|
251
|
+
] }),
|
|
252
|
+
c.replies.map((r, i) => /* @__PURE__ */ jsxs2("div", { className: "pc-msg", children: [
|
|
253
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-meta", children: [
|
|
254
|
+
r.author,
|
|
255
|
+
" \xB7 ",
|
|
256
|
+
when(r.createdAt)
|
|
257
|
+
] }),
|
|
258
|
+
r.text
|
|
259
|
+
] }, i)),
|
|
260
|
+
/* @__PURE__ */ jsx2(Field, { placeholder: "Reply", label: "Reply", submit: (text) => patchComment(c.id, { reply: { author: getConfig().author, text } }) }),
|
|
261
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-actions start", children: [
|
|
262
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: () => patchComment(c.id, { status: resolved ? "open" : "resolved" }), children: resolved ? "Reopen" : "Resolve" }),
|
|
263
|
+
!resolved && /* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: () => markComments([c.id], !c.queued), children: c.queued ? "Unmark" : "Mark for Agent" }),
|
|
264
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-ibtn pc-trash", onClick: () => deleteComment(c.id), "aria-label": "Delete comment", title: "Delete", children: /* @__PURE__ */ jsx2(Icon, { name: "trash" }) })
|
|
265
|
+
] })
|
|
266
|
+
] });
|
|
267
|
+
}
|
|
268
|
+
function Pin({ c, x, y }) {
|
|
269
|
+
const open = useC((s) => s.openId === c.id);
|
|
270
|
+
const flash = useC((s) => s.flashId === c.id);
|
|
271
|
+
return /* @__PURE__ */ jsx2(
|
|
272
|
+
"button",
|
|
273
|
+
{
|
|
274
|
+
className: `pc-pin${c.status === "resolved" ? " done" : ""}${c.queued ? " queued" : ""}${open ? " on" : ""}${flash ? " flash" : ""}`,
|
|
275
|
+
style: { left: x, top: y },
|
|
276
|
+
onClick: () => setC({ openId: open ? null : c.id, draft: null }),
|
|
277
|
+
"aria-label": `Comment ${c.n}`,
|
|
278
|
+
children: c.n
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
var FILTERS = [["open", "Open"], ["queued", "For Agent"], ["resolved", "Resolved"], ["all", "All"]];
|
|
283
|
+
var matches = (c, f) => f === "all" || (f === "queued" ? c.queued && c.status === "open" : c.status === f);
|
|
284
|
+
var groupBy = (items, key) => items.reduce((m, it) => {
|
|
285
|
+
var _a;
|
|
286
|
+
(m[_a = key(it)] || (m[_a] = [])).push(it);
|
|
287
|
+
return m;
|
|
288
|
+
}, {});
|
|
289
|
+
function History() {
|
|
290
|
+
const list = useC((s) => s.list);
|
|
291
|
+
const filter = useC((s) => s.filter);
|
|
292
|
+
const shown = list.filter((c) => matches(c, filter));
|
|
293
|
+
const groups = Object.entries(groupBy(shown, (c) => c.screenLabel));
|
|
294
|
+
const count = (f) => list.filter((c) => matches(c, f)).length;
|
|
295
|
+
const [confirm, setConfirm] = useState(false);
|
|
296
|
+
const [picked, setPicked] = useState([]);
|
|
297
|
+
const selectable = shown.filter((c) => c.status === "open").map((c) => c.id);
|
|
298
|
+
const sel = picked.filter((id) => selectable.includes(id));
|
|
299
|
+
const allPicked = selectable.length > 0 && sel.length === selectable.length;
|
|
300
|
+
const toggle = (id) => setPicked((p) => p.includes(id) ? p.filter((x) => x !== id) : [...p, id]);
|
|
301
|
+
const anyQueued = sel.some((id) => list.find((c) => c.id === id)?.queued);
|
|
302
|
+
const mark = (queued) => markComments(sel, queued).then(() => setPicked([]));
|
|
303
|
+
return /* @__PURE__ */ jsxs2("aside", { className: "pc-history", "aria-label": "Comments", children: [
|
|
304
|
+
/* @__PURE__ */ jsxs2("header", { children: [
|
|
305
|
+
/* @__PURE__ */ jsx2("b", { children: "Comments" }),
|
|
306
|
+
list.length > 0 && /* @__PURE__ */ jsx2("button", { className: "pc-btn ghost danger", onClick: () => setConfirm(true), children: "Delete all" }),
|
|
307
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => setC({ historyOpen: false }), "aria-label": "Close", title: "Close", children: /* @__PURE__ */ jsx2(Icon, { name: "x" }) })
|
|
308
|
+
] }),
|
|
309
|
+
confirm && /* @__PURE__ */ jsxs2("div", { className: "pc-confirm", children: [
|
|
310
|
+
/* @__PURE__ */ jsxs2("span", { children: [
|
|
311
|
+
"Delete all ",
|
|
312
|
+
list.length,
|
|
313
|
+
" comments, open and resolved? This can't be undone."
|
|
314
|
+
] }),
|
|
315
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-actions", children: [
|
|
316
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: () => setConfirm(false), children: "Cancel" }),
|
|
317
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-btn danger solid", onClick: () => deleteAllComments().then(() => setConfirm(false)), children: "Delete all" })
|
|
318
|
+
] })
|
|
319
|
+
] }),
|
|
320
|
+
/* @__PURE__ */ jsx2("div", { className: "pc-chips", children: FILTERS.map(([f, l]) => /* @__PURE__ */ jsxs2("button", { className: `pc-chip${filter === f ? " on" : ""}`, onClick: () => setC({ filter: f }), children: [
|
|
321
|
+
l,
|
|
322
|
+
" ",
|
|
323
|
+
count(f)
|
|
324
|
+
] }, f)) }),
|
|
325
|
+
selectable.length > 0 && /* @__PURE__ */ jsxs2("div", { className: "pc-selbar", children: [
|
|
326
|
+
/* @__PURE__ */ jsxs2("label", { className: "pc-check", children: [
|
|
327
|
+
/* @__PURE__ */ jsx2("input", { type: "checkbox", checked: allPicked, onChange: () => setPicked(allPicked ? [] : selectable) }),
|
|
328
|
+
sel.length ? `${sel.length} selected` : "Select all"
|
|
329
|
+
] }),
|
|
330
|
+
sel.length > 0 && anyQueued && /* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: () => mark(false), children: "Unmark" }),
|
|
331
|
+
sel.length > 0 && /* @__PURE__ */ jsxs2("button", { className: "pc-btn primary", onClick: () => mark(true), children: [
|
|
332
|
+
"Mark for Agent (",
|
|
333
|
+
sel.length,
|
|
334
|
+
")"
|
|
335
|
+
] })
|
|
336
|
+
] }),
|
|
337
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-groups", children: [
|
|
338
|
+
!shown.length && /* @__PURE__ */ jsx2("p", { className: "pc-empty", children: "No comments here. Press C and click an element to leave one." }),
|
|
339
|
+
groups.map(([label, items]) => /* @__PURE__ */ jsxs2("section", { children: [
|
|
340
|
+
/* @__PURE__ */ jsx2("h5", { children: label }),
|
|
341
|
+
items.map((c) => /* @__PURE__ */ jsxs2("div", { className: `pc-row${c.status === "resolved" ? " done" : ""}`, children: [
|
|
342
|
+
c.status === "open" ? /* @__PURE__ */ jsx2("input", { type: "checkbox", className: "pc-pick", checked: sel.includes(c.id), onChange: () => toggle(c.id), "aria-label": `Select comment ${c.n}` }) : /* @__PURE__ */ jsx2("span", { className: "pc-pick" }),
|
|
343
|
+
/* @__PURE__ */ jsxs2("button", { className: "pc-item", onClick: () => goToComment(c), children: [
|
|
344
|
+
/* @__PURE__ */ jsx2("span", { className: `pc-n${c.queued ? " queued" : ""}`, children: c.n }),
|
|
345
|
+
/* @__PURE__ */ jsxs2("span", { className: "pc-body", children: [
|
|
346
|
+
/* @__PURE__ */ jsxs2("span", { className: "pc-place", children: [
|
|
347
|
+
c.queued && /* @__PURE__ */ jsx2("i", { className: "pc-tag", children: "For Agent" }),
|
|
348
|
+
placeOf(c.anchor)
|
|
349
|
+
] }),
|
|
350
|
+
/* @__PURE__ */ jsx2("span", { className: "pc-text", children: c.text }),
|
|
351
|
+
/* @__PURE__ */ jsx2("span", { className: "pc-meta", children: [c.context, when(c.createdAt), c.replies.length && `${c.replies.length} ${c.replies.length > 1 ? "replies" : "reply"}`].filter(Boolean).join(" \xB7 ") })
|
|
352
|
+
] })
|
|
353
|
+
] })
|
|
354
|
+
] }, c.id))
|
|
355
|
+
] }, label))
|
|
356
|
+
] })
|
|
357
|
+
] });
|
|
358
|
+
}
|
|
359
|
+
var POS_KEY = "proto-comments:toolbar";
|
|
360
|
+
function Toolbar() {
|
|
361
|
+
const mode = useC((c) => c.mode);
|
|
362
|
+
const historyOpen = useC((c) => c.historyOpen);
|
|
363
|
+
const open = useC((c) => c.list.filter((x) => x.status === "open").length);
|
|
364
|
+
const [pos, setPos] = useState(null);
|
|
365
|
+
const drag = useRef(null);
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
try {
|
|
368
|
+
const p = JSON.parse(localStorage.getItem(POS_KEY));
|
|
369
|
+
if (p) setPos(p);
|
|
370
|
+
} catch {
|
|
371
|
+
}
|
|
372
|
+
}, []);
|
|
373
|
+
const onDown = (e) => {
|
|
374
|
+
const r = e.currentTarget.parentElement.getBoundingClientRect();
|
|
375
|
+
drag.current = { dx: e.clientX - r.left, dy: e.clientY - r.top, w: r.width, h: r.height };
|
|
376
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
377
|
+
};
|
|
378
|
+
const onMove = (e) => {
|
|
379
|
+
const d = drag.current;
|
|
380
|
+
if (!d) return;
|
|
381
|
+
setPos({
|
|
382
|
+
x: Math.max(8, Math.min(e.clientX - d.dx, window.innerWidth - d.w - 8)),
|
|
383
|
+
y: Math.max(8, Math.min(e.clientY - d.dy, window.innerHeight - d.h - 8))
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
const onUp = () => {
|
|
387
|
+
drag.current = null;
|
|
388
|
+
try {
|
|
389
|
+
localStorage.setItem(POS_KEY, JSON.stringify(pos));
|
|
390
|
+
} catch {
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
return /* @__PURE__ */ jsxs2("div", { className: "pc-bar", style: pos ? { left: pos.x, top: pos.y, bottom: "auto", transform: "none" } : void 0, role: "toolbar", "aria-label": "Comments", children: [
|
|
394
|
+
/* @__PURE__ */ jsx2("span", { className: "pc-grip", onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, "aria-hidden": "true", title: "Drag", children: /* @__PURE__ */ jsx2(Grip, {}) }),
|
|
395
|
+
/* @__PURE__ */ jsxs2("button", { "aria-pressed": mode, onClick: () => setC({ mode: !getC().mode, draft: null, openId: null }), title: mode ? "Stop commenting (V)" : "Comment (C)", children: [
|
|
396
|
+
/* @__PURE__ */ jsx2(Icon, { name: "comment" }),
|
|
397
|
+
/* @__PURE__ */ jsx2("span", { children: "Comment" }),
|
|
398
|
+
/* @__PURE__ */ jsx2("kbd", { children: mode ? "V" : "C" })
|
|
399
|
+
] }),
|
|
400
|
+
/* @__PURE__ */ jsxs2("button", { "aria-pressed": historyOpen, onClick: () => setC({ historyOpen: !historyOpen }), title: "All comments", children: [
|
|
401
|
+
/* @__PURE__ */ jsx2(Icon, { name: "list" }),
|
|
402
|
+
/* @__PURE__ */ jsx2("span", { children: "History" }),
|
|
403
|
+
open > 0 && /* @__PURE__ */ jsx2("b", { children: open })
|
|
404
|
+
] })
|
|
405
|
+
] });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/styles.js
|
|
409
|
+
var styles_default = `
|
|
410
|
+
.pc-root{
|
|
411
|
+
--pc-dark:#262A3D; --pc-dark-hover:#30354B; --pc-dark-text:#C5CAD8; --pc-dark-kbd:#3A3F57;
|
|
412
|
+
--pc-primary:#3366DD; --pc-primary-hover:#2A56C0;
|
|
413
|
+
--pc-surface:#FFFFFF; --pc-surface-2:#F6F7F9; --pc-surface-3:#EEF0F4;
|
|
414
|
+
--pc-border:#E2E5EB; --pc-border-strong:#CDD2DB;
|
|
415
|
+
--pc-text:#3B4152; --pc-text-strong:#2B3040; --pc-muted:#7A8194; --pc-faint:#A3A9B8;
|
|
416
|
+
--pc-danger:#C93C3C; --pc-danger-soft:#FDEDED; --pc-warn:#A86A00; --pc-warn-soft:#FFF3DC;
|
|
417
|
+
--pc-agent:#6B5BD6; --pc-agent-soft:#EFEDFC;
|
|
418
|
+
--pc-shadow:0 12px 32px rgba(30,35,55,.16), 0 2px 6px rgba(30,35,55,.08);
|
|
419
|
+
--pc-z:2147483000;
|
|
420
|
+
font:13px/1.45 Inter, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color:var(--pc-text);
|
|
421
|
+
-webkit-font-smoothing:antialiased;
|
|
422
|
+
}
|
|
423
|
+
.pc-root *, .pc-root *::before, .pc-root *::after{ box-sizing:border-box; }
|
|
424
|
+
.pc-root button{ font:inherit; color:inherit; cursor:pointer; }
|
|
425
|
+
.pc-root textarea, .pc-root input{ font:inherit; color:inherit; }
|
|
426
|
+
.pc-root :focus-visible{ outline:2px solid var(--pc-primary); outline-offset:2px; }
|
|
427
|
+
.pc-i{ flex:none; }
|
|
428
|
+
|
|
429
|
+
html.pc-mode, html.pc-mode *{ cursor:crosshair !important; }
|
|
430
|
+
html.pc-mode .pc-root, html.pc-mode .pc-root *{ cursor:auto !important; }
|
|
431
|
+
html.pc-mode .pc-root button, html.pc-mode .pc-root label{ cursor:pointer !important; }
|
|
432
|
+
html.pc-mode .pc-grip{ cursor:grab !important; }
|
|
433
|
+
|
|
434
|
+
/* buttons */
|
|
435
|
+
.pc-btn{ display:inline-flex; align-items:center; justify-content:center; gap:6px; height:30px; padding:0 10px; border-radius:6px; border:1px solid var(--pc-border-strong); background:var(--pc-surface); color:var(--pc-text); font-weight:500; white-space:nowrap; }
|
|
436
|
+
.pc-btn:hover{ border-color:var(--pc-muted); }
|
|
437
|
+
.pc-btn.primary{ background:var(--pc-primary); border-color:var(--pc-primary); color:#fff; }
|
|
438
|
+
.pc-btn.primary:hover{ background:var(--pc-primary-hover); }
|
|
439
|
+
.pc-btn[disabled]{ opacity:.5; cursor:not-allowed; }
|
|
440
|
+
.pc-btn.ghost{ border-color:transparent; background:transparent; padding:0 8px; }
|
|
441
|
+
.pc-btn.danger{ color:var(--pc-danger); }
|
|
442
|
+
.pc-btn.danger:hover{ background:var(--pc-danger-soft); }
|
|
443
|
+
.pc-btn.danger.solid{ background:var(--pc-danger); border-color:var(--pc-danger); color:#fff; }
|
|
444
|
+
.pc-btn.danger.solid:hover{ filter:brightness(.92); }
|
|
445
|
+
.pc-ibtn{ width:28px; height:28px; display:inline-grid; place-items:center; border:0; border-radius:6px; background:transparent; color:var(--pc-muted); }
|
|
446
|
+
.pc-ibtn:hover{ background:var(--pc-surface-3); color:var(--pc-text); }
|
|
447
|
+
.pc-trash{ margin-left:auto; }
|
|
448
|
+
.pc-trash:hover{ color:var(--pc-danger); background:var(--pc-danger-soft); }
|
|
449
|
+
.pc-chip{ height:28px; padding:0 10px; border-radius:14px; border:1px solid var(--pc-border-strong); background:var(--pc-surface); color:var(--pc-text); }
|
|
450
|
+
.pc-chip:hover{ border-color:var(--pc-muted); }
|
|
451
|
+
.pc-chip.on{ background:var(--pc-dark); border-color:var(--pc-dark); color:#fff; }
|
|
452
|
+
.pc-tag{ display:inline-flex; align-items:center; height:18px; padding:0 7px; margin-right:6px; border-radius:9px; background:var(--pc-agent-soft); color:var(--pc-agent); font-style:normal; font-size:10px; font-weight:600; letter-spacing:.02em; }
|
|
453
|
+
|
|
454
|
+
/* toolbar */
|
|
455
|
+
.pc-bar{ position:fixed; z-index:calc(var(--pc-z) + 4); left:50%; transform:translateX(-50%); bottom:calc(16px + env(safe-area-inset-bottom,0px)); display:flex; align-items:center; gap:2px; padding:4px; border-radius:10px; background:var(--pc-dark); color:var(--pc-dark-text); box-shadow:var(--pc-shadow); user-select:none; }
|
|
456
|
+
.pc-bar button{ display:inline-flex; align-items:center; gap:6px; height:30px; padding:0 10px; border:0; border-radius:7px; background:transparent; color:inherit; }
|
|
457
|
+
.pc-bar button:hover{ background:var(--pc-dark-hover); color:#fff; }
|
|
458
|
+
.pc-bar button[aria-pressed="true"]{ background:var(--pc-primary); color:#fff; }
|
|
459
|
+
.pc-bar kbd{ font:inherit; font-size:11px; padding:0 5px; border-radius:4px; background:var(--pc-dark-kbd); color:#D9DCE6; }
|
|
460
|
+
.pc-bar b{ min-width:18px; height:18px; padding:0 5px; border-radius:9px; background:var(--pc-primary); color:#fff; font-size:11px; display:grid; place-items:center; }
|
|
461
|
+
.pc-grip{ display:grid; place-items:center; width:20px; height:30px; margin-left:2px; opacity:.6; cursor:grab; touch-action:none; }
|
|
462
|
+
.pc-grip:hover{ opacity:1; }
|
|
463
|
+
.pc-hint{ position:fixed; z-index:calc(var(--pc-z) + 1); left:50%; bottom:calc(68px + env(safe-area-inset-bottom,0px)); transform:translateX(-50%); background:var(--pc-dark); color:#fff; padding:8px 12px; border-radius:8px; box-shadow:var(--pc-shadow); }
|
|
464
|
+
|
|
465
|
+
/* pins and popovers */
|
|
466
|
+
.pc-outline{ position:fixed; z-index:var(--pc-z); pointer-events:none; outline:2px solid var(--pc-primary); outline-offset:1px; border-radius:3px; background:rgba(51,102,221,.06); }
|
|
467
|
+
.pc-outline.flash{ animation:pc-flash 1.6s ease-out forwards; }
|
|
468
|
+
@keyframes pc-flash{ 0%,60%{ opacity:1 } 100%{ opacity:0 } }
|
|
469
|
+
.pc-pin{ position:fixed; z-index:calc(var(--pc-z) + 1); transform:translate(-2px,-100%); min-width:26px; height:26px; padding:0 7px; border:2px solid #fff; border-radius:13px 13px 13px 2px; background:var(--pc-primary); color:#fff !important; font-size:12px !important; font-weight:600; box-shadow:var(--pc-shadow); }
|
|
470
|
+
.pc-pin.on{ background:var(--pc-dark); }
|
|
471
|
+
.pc-pin.queued, .pc-n.queued{ background:var(--pc-agent); }
|
|
472
|
+
.pc-pin.done{ background:var(--pc-faint); }
|
|
473
|
+
.pc-pin.flash{ animation:pc-pin .5s ease-out 2; }
|
|
474
|
+
@keyframes pc-pin{ 50%{ transform:translate(-2px,-100%) scale(1.25) } }
|
|
475
|
+
.pc-pop{ position:fixed; z-index:calc(var(--pc-z) + 2); width:320px; padding:12px; display:flex; flex-direction:column; gap:10px; background:var(--pc-surface); border:1px solid var(--pc-border); border-radius:8px; box-shadow:var(--pc-shadow); max-height:calc(100vh - 16px); overflow:auto; }
|
|
476
|
+
.pc-head{ display:flex; align-items:center; gap:8px; }
|
|
477
|
+
.pc-head span{ flex:1; color:var(--pc-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
478
|
+
.pc-head .pc-tag{ margin:0; }
|
|
479
|
+
.pc-target, .pc-place{ font-size:12px; color:var(--pc-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
480
|
+
.pc-head + .pc-target{ margin-top:-4px; }
|
|
481
|
+
.pc-input{ width:100%; resize:vertical; border:1px solid var(--pc-border-strong); border-radius:6px; padding:8px 10px; font-size:14px !important; background:var(--pc-surface); }
|
|
482
|
+
.pc-input:focus{ outline:none; border-color:var(--pc-primary); }
|
|
483
|
+
.pc-actions{ display:flex; justify-content:flex-end; gap:8px; }
|
|
484
|
+
.pc-actions.start{ justify-content:flex-start; border-top:1px solid var(--pc-border); padding-top:10px; }
|
|
485
|
+
.pc-msg{ font-size:14px; color:var(--pc-text-strong); white-space:pre-wrap; }
|
|
486
|
+
.pc-meta{ display:block; font-size:12px; color:var(--pc-muted); margin-bottom:2px; }
|
|
487
|
+
.pc-lost{ font-size:12px; color:var(--pc-warn); background:var(--pc-warn-soft); border-radius:6px; padding:6px 8px; }
|
|
488
|
+
|
|
489
|
+
/* history */
|
|
490
|
+
.pc-history{ position:fixed; z-index:calc(var(--pc-z) + 3); top:0; right:0; bottom:0; width:360px; max-width:100%; background:var(--pc-surface); border-left:1px solid var(--pc-border); box-shadow:var(--pc-shadow); display:flex; flex-direction:column; }
|
|
491
|
+
.pc-history header{ display:flex; align-items:center; gap:4px; padding:12px 12px 8px 16px; font-size:15px; }
|
|
492
|
+
.pc-history header b{ flex:1; color:var(--pc-text-strong); }
|
|
493
|
+
.pc-confirm{ margin:0 16px 12px; padding:10px 12px; border-radius:8px; background:var(--pc-danger-soft); color:var(--pc-text-strong); display:flex; flex-direction:column; gap:8px; }
|
|
494
|
+
.pc-chips{ display:flex; flex-wrap:wrap; gap:6px; padding:0 16px 12px; border-bottom:1px solid var(--pc-border); }
|
|
495
|
+
.pc-selbar{ display:flex; align-items:center; gap:8px; min-height:44px; padding:6px 16px; border-bottom:1px solid var(--pc-border); }
|
|
496
|
+
.pc-check{ flex:1; display:inline-flex; align-items:center; gap:10px; cursor:pointer; }
|
|
497
|
+
.pc-check input, .pc-pick{ width:16px; height:16px; margin:0; accent-color:var(--pc-primary); flex:none; }
|
|
498
|
+
.pc-groups{ flex:1; overflow:auto; padding:8px 0 16px; }
|
|
499
|
+
.pc-groups h5{ margin:12px 16px 4px; font-size:12px; font-weight:600; color:var(--pc-muted); text-transform:uppercase; letter-spacing:.04em; }
|
|
500
|
+
.pc-row{ display:flex; align-items:flex-start; padding-left:16px; }
|
|
501
|
+
.pc-row:hover{ background:var(--pc-surface-2); }
|
|
502
|
+
.pc-pick{ margin-top:11px; }
|
|
503
|
+
.pc-item{ flex:1; min-width:0; display:flex; gap:10px; padding:8px 16px 8px 10px; border:0; background:transparent; text-align:left; }
|
|
504
|
+
.pc-n{ flex:none; min-width:22px; height:22px; padding:0 6px; border-radius:11px 11px 11px 2px; background:var(--pc-primary); color:#fff; font-size:11px; font-weight:600; display:grid; place-items:center; }
|
|
505
|
+
.pc-row.done .pc-n{ background:var(--pc-faint); }
|
|
506
|
+
.pc-body{ min-width:0; display:flex; flex-direction:column; gap:2px; }
|
|
507
|
+
.pc-text{ color:var(--pc-text-strong); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
|
508
|
+
.pc-row.done .pc-text{ color:var(--pc-muted); }
|
|
509
|
+
.pc-empty{ margin:16px; color:var(--pc-muted); }
|
|
510
|
+
`;
|
|
511
|
+
|
|
512
|
+
// src/index.jsx
|
|
513
|
+
import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
514
|
+
var TEXT_INPUT = 'input:not([type=checkbox]):not([type=radio]):not([type=button]):not([type=submit]):not([type=range]):not([type=color]), textarea, [contenteditable="true"]';
|
|
515
|
+
var typing = (t) => t.closest?.(TEXT_INPUT);
|
|
516
|
+
var ours = (t) => t.closest?.(".pc-root");
|
|
517
|
+
var BLOCK = ["pointerdown", "mousedown", "mouseup", "click", "dblclick"];
|
|
518
|
+
var rectOf = (el) => {
|
|
519
|
+
const r = el.getBoundingClientRect();
|
|
520
|
+
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
521
|
+
};
|
|
522
|
+
function useStyles() {
|
|
523
|
+
useEffect2(() => {
|
|
524
|
+
if (document.getElementById("proto-comments-css")) return;
|
|
525
|
+
const s = document.createElement("style");
|
|
526
|
+
s.id = "proto-comments-css";
|
|
527
|
+
s.textContent = styles_default;
|
|
528
|
+
document.head.appendChild(s);
|
|
529
|
+
}, []);
|
|
530
|
+
}
|
|
531
|
+
function useFrame(list, openId, flashId) {
|
|
532
|
+
const [frame, setFrame] = useState2({ screen: null, pos: {} });
|
|
533
|
+
useEffect2(() => {
|
|
534
|
+
let raf, last = "";
|
|
535
|
+
const tick = () => {
|
|
536
|
+
const screen = getConfig().getScreen().key;
|
|
537
|
+
const pos = {};
|
|
538
|
+
list.forEach((c) => {
|
|
539
|
+
if (c.screen !== screen || c.status !== "open" && c.id !== openId) return;
|
|
540
|
+
const el = findAnchor(c.anchor);
|
|
541
|
+
if (!el || !el.getClientRects().length) return;
|
|
542
|
+
const r = rectOf(el);
|
|
543
|
+
pos[c.id] = { x: r.left + c.anchor.offset.x * r.width, y: r.top + c.anchor.offset.y * r.height, rect: c.id === flashId ? r : null };
|
|
544
|
+
});
|
|
545
|
+
const key = JSON.stringify([screen, pos]);
|
|
546
|
+
if (key !== last) {
|
|
547
|
+
last = key;
|
|
548
|
+
setFrame({ screen, pos });
|
|
549
|
+
}
|
|
550
|
+
raf = requestAnimationFrame(tick);
|
|
551
|
+
};
|
|
552
|
+
tick();
|
|
553
|
+
return () => cancelAnimationFrame(raf);
|
|
554
|
+
}, [list, openId, flashId]);
|
|
555
|
+
return frame;
|
|
556
|
+
}
|
|
557
|
+
function ProtoComments(props) {
|
|
558
|
+
const cfg2 = { ...DEFAULTS, ...Object.fromEntries(Object.entries(props).filter(([, v]) => v !== void 0)) };
|
|
559
|
+
setConfig(cfg2);
|
|
560
|
+
useStyles();
|
|
561
|
+
const [root, setRoot] = useState2(null);
|
|
562
|
+
useEffect2(() => {
|
|
563
|
+
const el = document.createElement("div");
|
|
564
|
+
el.className = "pc-root";
|
|
565
|
+
document.body.appendChild(el);
|
|
566
|
+
setRoot(el);
|
|
567
|
+
return () => el.remove();
|
|
568
|
+
}, []);
|
|
569
|
+
const { list, mode, draft, openId, historyOpen, flashId } = useC((s) => s);
|
|
570
|
+
const [hover, setHover] = useState2(null);
|
|
571
|
+
useEffect2(() => {
|
|
572
|
+
const load = () => {
|
|
573
|
+
if (document.visibilityState === "visible") return loadComments().catch((e) => console.warn(e.message));
|
|
574
|
+
};
|
|
575
|
+
Promise.resolve(load()).then(resumePendingGo);
|
|
576
|
+
document.addEventListener("visibilitychange", load);
|
|
577
|
+
window.addEventListener("focus", load);
|
|
578
|
+
return () => {
|
|
579
|
+
document.removeEventListener("visibilitychange", load);
|
|
580
|
+
window.removeEventListener("focus", load);
|
|
581
|
+
};
|
|
582
|
+
}, []);
|
|
583
|
+
useEffect2(() => {
|
|
584
|
+
const onKey = (e) => {
|
|
585
|
+
const c = getC();
|
|
586
|
+
if (e.key === "Escape" && (c.draft || c.openId || c.historyOpen)) {
|
|
587
|
+
e.stopPropagation();
|
|
588
|
+
if (c.draft) setC({ draft: null });
|
|
589
|
+
else if (c.openId) setC({ openId: null });
|
|
590
|
+
else setC({ historyOpen: false });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
594
|
+
if (e.code === "KeyV" && c.mode && !(typing(e.target) && ours(e.target))) {
|
|
595
|
+
e.preventDefault();
|
|
596
|
+
e.stopPropagation();
|
|
597
|
+
setC({ mode: false, draft: null });
|
|
598
|
+
}
|
|
599
|
+
if (e.code === "KeyC" && !c.mode && !typing(e.target)) setC({ mode: true, openId: null });
|
|
600
|
+
};
|
|
601
|
+
window.addEventListener("keydown", onKey, true);
|
|
602
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
603
|
+
}, []);
|
|
604
|
+
useEffect2(() => {
|
|
605
|
+
if (!mode) {
|
|
606
|
+
setHover(null);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const onMove = (e) => setHover(ours(e.target) ? null : rectOf(e.target));
|
|
610
|
+
const block = (e) => {
|
|
611
|
+
if (ours(e.target)) return;
|
|
612
|
+
e.preventDefault();
|
|
613
|
+
e.stopPropagation();
|
|
614
|
+
if (e.type === "click") setC({ draft: { anchor: anchorFor(e.target, e.clientX, e.clientY, cfg2.headingSelector), x: e.clientX, y: e.clientY }, openId: null });
|
|
615
|
+
};
|
|
616
|
+
document.addEventListener("mousemove", onMove, true);
|
|
617
|
+
BLOCK.forEach((t) => document.addEventListener(t, block, true));
|
|
618
|
+
if (!ours(document.activeElement)) document.activeElement?.blur();
|
|
619
|
+
document.documentElement.classList.add("pc-mode");
|
|
620
|
+
return () => {
|
|
621
|
+
document.removeEventListener("mousemove", onMove, true);
|
|
622
|
+
BLOCK.forEach((t) => document.removeEventListener(t, block, true));
|
|
623
|
+
document.documentElement.classList.remove("pc-mode");
|
|
624
|
+
};
|
|
625
|
+
}, [mode, cfg2.headingSelector]);
|
|
626
|
+
const { pos } = useFrame(list, openId, flashId);
|
|
627
|
+
const here = list.filter((c) => pos[c.id]);
|
|
628
|
+
const open = list.find((c) => c.id === openId);
|
|
629
|
+
if (!root) return null;
|
|
630
|
+
return createPortal(
|
|
631
|
+
/* @__PURE__ */ jsxs3(Fragment3, { children: [
|
|
632
|
+
hover && !draft && /* @__PURE__ */ jsx3("div", { className: "pc-outline", style: hover }),
|
|
633
|
+
here.map((c) => /* @__PURE__ */ jsx3(Pin, { c, x: pos[c.id].x, y: pos[c.id].y }, c.id)),
|
|
634
|
+
here.map((c) => pos[c.id].rect && /* @__PURE__ */ jsx3("div", { className: "pc-outline flash", style: pos[c.id].rect }, `f${c.id}`)),
|
|
635
|
+
draft && /* @__PURE__ */ jsx3(Draft, { draft }),
|
|
636
|
+
open && (pos[open.id] ? /* @__PURE__ */ jsx3(Thread, { c: open, x: pos[open.id].x, y: pos[open.id].y }) : /* @__PURE__ */ jsx3(Thread, { c: open, x: window.innerWidth / 2 - 160, y: 120, lost: true })),
|
|
637
|
+
historyOpen && /* @__PURE__ */ jsx3(History, {}),
|
|
638
|
+
/* @__PURE__ */ jsx3(Toolbar, {}),
|
|
639
|
+
mode && /* @__PURE__ */ jsx3("div", { className: "pc-hint", children: "Click an element to comment \xB7 V to stop" })
|
|
640
|
+
] }),
|
|
641
|
+
root
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
var index_default = ProtoComments;
|
|
645
|
+
export {
|
|
646
|
+
ProtoComments,
|
|
647
|
+
index_default as default,
|
|
648
|
+
placeOf
|
|
649
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proto-comments",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Figma-style review comments on a running React prototype, split by screen even in single-URL apps, stored in a JSON file a coding agent can apply.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["react", "prototype", "comments", "review", "feedback", "annotations", "design", "ai-agent", "claude"],
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.js",
|
|
12
|
+
"./next": "./server/next.js",
|
|
13
|
+
"./storage": "./server/storage.js",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"bin": { "proto-comments": "./bin/proto-comments.js" },
|
|
17
|
+
"files": ["dist", "server", "bin", "README.md", "LICENSE"],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"engines": { "node": ">=18" },
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "node build.mjs",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": ">=18",
|
|
26
|
+
"react-dom": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"esbuild": "0.24.2"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/server/next.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createStorage } from './storage.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Route handlers for a Next.js App Router route, e.g. app/api/comments/route.js:
|
|
5
|
+
*
|
|
6
|
+
* import { createNextHandlers } from 'proto-comments/next';
|
|
7
|
+
* export const dynamic = 'force-dynamic';
|
|
8
|
+
* export const { GET, POST, PATCH, DELETE } = createNextHandlers();
|
|
9
|
+
*
|
|
10
|
+
* Off outside `next dev` unless `enabled` is set.
|
|
11
|
+
*/
|
|
12
|
+
export function createNextHandlers({ file, enabled = process.env.NODE_ENV === 'development' } = {}) {
|
|
13
|
+
const storage = createStorage(file);
|
|
14
|
+
const run = method => async req => {
|
|
15
|
+
if (!enabled) return new Response('Not found', { status: 404 });
|
|
16
|
+
/* Same-origin JSON only: a page on another site must not be able to write comments, even with a "simple" request. */
|
|
17
|
+
const origin = req.headers.get('origin');
|
|
18
|
+
if (origin && new URL(origin).host !== req.headers.get('host')) return new Response('Forbidden', { status: 403 });
|
|
19
|
+
if (method !== 'GET' && !(req.headers.get('content-type') || '').startsWith('application/json')) return new Response('Unsupported Media Type', { status: 415 });
|
|
20
|
+
const body = method === 'GET' ? null : await req.json();
|
|
21
|
+
const { status, json } = await storage.handle(method, body);
|
|
22
|
+
return Response.json(json, { status });
|
|
23
|
+
};
|
|
24
|
+
return { GET: run('GET'), POST: run('POST'), PATCH: run('PATCH'), DELETE: run('DELETE') };
|
|
25
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/* Comments live in one JSON file in the project, so a coding agent can read and resolve them directly. */
|
|
5
|
+
export const DEFAULT_FILE = 'comments/comments.json';
|
|
6
|
+
|
|
7
|
+
export function createStorage(file = DEFAULT_FILE) {
|
|
8
|
+
const abs = path.resolve(process.cwd(), file);
|
|
9
|
+
/* Writes go one at a time, so two quick requests can't overwrite each other. */
|
|
10
|
+
let queue = Promise.resolve();
|
|
11
|
+
const locked = fn => (queue = queue.then(fn, fn));
|
|
12
|
+
|
|
13
|
+
async function read() {
|
|
14
|
+
try { return JSON.parse(await fs.readFile(abs, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
15
|
+
}
|
|
16
|
+
async function write(list) {
|
|
17
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
18
|
+
await fs.writeFile(abs, JSON.stringify(list, null, 2) + '\n');
|
|
19
|
+
}
|
|
20
|
+
const now = () => new Date().toISOString();
|
|
21
|
+
|
|
22
|
+
/** One entry point for every transport (Next route, standalone server): returns { status, json }. */
|
|
23
|
+
async function handle(method, body) {
|
|
24
|
+
if (method === 'GET') return { status: 200, json: await read() };
|
|
25
|
+
return locked(async () => {
|
|
26
|
+
const list = await read();
|
|
27
|
+
if (method === 'POST') {
|
|
28
|
+
const { screen, screenLabel, context, snapshot, anchor, text, author } = body;
|
|
29
|
+
const n = Math.max(0, ...list.map(c => c.n)) + 1;
|
|
30
|
+
const c = { id: `c${Date.now().toString(36)}`, n, screen, screenLabel, context, anchor, text, author, status: 'open', queued: false, createdAt: now(), replies: [], snapshot };
|
|
31
|
+
await write([...list, c]);
|
|
32
|
+
return { status: 200, json: c };
|
|
33
|
+
}
|
|
34
|
+
if (method === 'PATCH') {
|
|
35
|
+
const { id, ids, queued, status, reply } = body;
|
|
36
|
+
if (ids) {
|
|
37
|
+
list.forEach(x => { if (ids.includes(x.id)) x.queued = !!queued; });
|
|
38
|
+
await write(list);
|
|
39
|
+
return { status: 200, json: list };
|
|
40
|
+
}
|
|
41
|
+
const c = list.find(x => x.id === id);
|
|
42
|
+
if (!c) return { status: 404, json: { error: 'Not found' } };
|
|
43
|
+
if (status) c.status = status;
|
|
44
|
+
if (status === 'resolved') c.queued = false;
|
|
45
|
+
if (reply) c.replies.push({ ...reply, createdAt: now() });
|
|
46
|
+
await write(list);
|
|
47
|
+
return { status: 200, json: c };
|
|
48
|
+
}
|
|
49
|
+
if (method === 'DELETE') {
|
|
50
|
+
await write(body.all ? [] : list.filter(x => x.id !== body.id));
|
|
51
|
+
return { status: 200, json: { ok: true } };
|
|
52
|
+
}
|
|
53
|
+
return { status: 405, json: { error: 'Method not allowed' } };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { file: abs, read, write, handle };
|
|
58
|
+
}
|