@supersuit/artifacts 0.32.0 → 0.33.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/CHANGELOG.md +38 -0
- package/README.md +51 -2
- package/lib/artifacts/comments.d.ts +24 -0
- package/lib/artifacts/comments.js +23 -0
- package/lib/artifacts/state-store.d.ts +10 -0
- package/lib/artifacts/state-store.js +29 -0
- package/lib/reader/comment-layer.d.ts +3 -1
- package/lib/reader/comment-layer.js +50 -13
- package/lib/routes/artifacts.d.ts +5 -0
- package/lib/routes/state-routes.d.ts +1 -0
- package/lib/routes/state-routes.js +113 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,44 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.33.0
|
|
7
|
+
|
|
8
|
+
**A comment thread can be resolved** (Gary, 2026-10-12: "Are you going to resolve the comments at
|
|
9
|
+
some point?"). Until now a shared comment could only be replied to, so a page whose every comment
|
|
10
|
+
had been dealt with still read as a page full of open questions, and the publisher's notifier kept
|
|
11
|
+
listing them.
|
|
12
|
+
|
|
13
|
+
- `POST /api/artifacts/<id>/resolve` (`RESOLVE`), body `{ entry, resolved?, note?, by? }`: the
|
|
14
|
+
publish key or a publisher pass (an agent resolves with the credential it publishes with), or a
|
|
15
|
+
reader signed in as `ownerEmail` or one of `admins`, same-origin JSON. Nobody else: 401 signed
|
|
16
|
+
out, 403 signed in, before the page is looked up. `entry` may name a reply; the thread's top
|
|
17
|
+
comment is what is resolved. `resolved: false` reopens. `note` is one line of at most 280.
|
|
18
|
+
- Recorded on the top comment's value as `resolved: { at, by, note? }`, where `by` is a first name
|
|
19
|
+
or what the publisher calls itself, never an email. A reader cannot write the field (the
|
|
20
|
+
comment check refuses it), and an edit keeps it.
|
|
21
|
+
- On the page a resolved thread draws a muted check pin with no box, opens collapsed to
|
|
22
|
+
"Resolved: <note>" with the comment and replies a tap away, and leaves the Comments count. The
|
|
23
|
+
owner and admins get Resolve and Reopen in every shared thread.
|
|
24
|
+
- The comments feed carries `resolved` on every row (a reply carries its thread's), takes
|
|
25
|
+
`&open=1` to leave resolved threads out (a reply written after the resolution stays in), and
|
|
26
|
+
answers `cursor` and `more` so a filtered caller can page.
|
|
27
|
+
- `StateStore.setResolved` (optional), implemented by `createStateStore` and
|
|
28
|
+
`createMemoryStateStore`.
|
|
29
|
+
- DETECTOR: `POST /api/artifacts/<id>/resolve` with the publish key answers 404 from the host's
|
|
30
|
+
router (no route file), or the feed's rows have no `resolved` key: 0.32.0 or earlier.
|
|
31
|
+
- REMEDY: update the dependency and add `app/api/artifacts/[id]/resolve/route.ts` exporting
|
|
32
|
+
`POST` delegating to `RESOLVE`. A host with its own `StateStore` implements `setResolved`, or the
|
|
33
|
+
route answers 501. No index or data migration: an open thread is one with no `resolved`.
|
|
34
|
+
- Tests: `routes/resolve.test.tsx` (anonymous and signed-in non-admin refused, the comment's own
|
|
35
|
+
writer refused; owner, admin, publish key and publisher pass allowed; origin and JSON for the
|
|
36
|
+
cookie path; note length and lines; a reply resolves its thread; reopen; 501s; readers and the
|
|
37
|
+
writer shown the resolution; a forged `resolved` refused; an edit keeps it; the feed's field,
|
|
38
|
+
`open=1`, `cursor`), `artifacts/comments.test.ts` (note rules, `resolutionOf`),
|
|
39
|
+
`reader/comment-layer.test.tsx` (muted pin, no box, open count, collapsed line, expand, owner
|
|
40
|
+
Resolve and Reopen post the route, a reader gets neither, an edit never sends `resolved`),
|
|
41
|
+
`routes/publisher-pass.test.tsx` (RESOLVE takes a pass exactly as the key), and `check-packed`
|
|
42
|
+
(the tarball resolves through the fixture's route, the feed carries it, `open=1` drops it).
|
|
43
|
+
|
|
6
44
|
## 0.32.0
|
|
7
45
|
|
|
8
46
|
**The page's owner and the hub's admins always get past a locked page's door** (Gary, 2026-10-11).
|
package/README.md
CHANGED
|
@@ -83,6 +83,7 @@ The rest, each exporting the named handler(s) from `artifacts`:
|
|
|
83
83
|
| `app/api/reader/leave/route.ts` | `GET` = `LEAVE` |
|
|
84
84
|
| `app/api/reader/ack/route.ts` | `POST` = `ACK` |
|
|
85
85
|
| `app/api/reader/track/route.ts` | `POST` = `TRACK` |
|
|
86
|
+
| `app/api/artifacts/[id]/resolve/route.ts` | `POST` = `RESOLVE` (see [resolving a thread](#resolving-a-thread)) |
|
|
86
87
|
| `app/api/comments/route.ts` | `GET` = `COMMENTS_FEED` (see [the comments feed](#the-comments-feed)) |
|
|
87
88
|
|
|
88
89
|
Pages live at `/<id>` by default. Mounting inside a larger site, pass `pagePrefix: '/a/'` and put
|
|
@@ -702,6 +703,48 @@ The bucket also needs CORS allowing `PUT` from the site's origin with the `conte
|
|
|
702
703
|
`cache-control` and `x-goog-content-length-range` headers, since the reader's browser uploads
|
|
703
704
|
directly.
|
|
704
705
|
|
|
706
|
+
### Resolving a thread
|
|
707
|
+
|
|
708
|
+
When what a comment asked for has been done, the thread is resolved. On the page it then draws a
|
|
709
|
+
muted check pin with no box over the page, opens collapsed to one line, **"Resolved: <note>"** (or
|
|
710
|
+
**"Resolved"**), with the comment and its replies a tap away, and leaves the Comments count. The
|
|
711
|
+
reader who left the comment sees the note. The owner and the hub's admins get **Resolve** (with an
|
|
712
|
+
optional note) and **Reopen** in every shared thread.
|
|
713
|
+
|
|
714
|
+
```
|
|
715
|
+
POST /api/artifacts/<id>/resolve
|
|
716
|
+
Authorization: Bearer <publish key or publisher pass>
|
|
717
|
+
Content-Type: application/json
|
|
718
|
+
|
|
719
|
+
{ "entry": "<entryId>", "note": "Named the river in the second paragraph.", "by": "Pat's agent" }
|
|
720
|
+
|
|
721
|
+
200 { id, entry, resolved: { at, by, note? } }
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
- **Who:** the publish key or a publisher pass, so an agent resolves with the credential it
|
|
725
|
+
publishes with; or a reader signed in as `ownerEmail` or one of `admins`, from this host's own
|
|
726
|
+
pages, as JSON. Anyone else is refused (401 signed out, 403 signed in) before the page is looked
|
|
727
|
+
up. Leaving a comment does not make you able to resolve it.
|
|
728
|
+
- **`entry`** is a comment's entry id, as the feed and `/responses` give it. Naming a reply
|
|
729
|
+
resolves the thread it belongs to; the answer's `entry` is that thread's top comment, which is
|
|
730
|
+
where `resolved` is stored (in the comment's value, beside `body`).
|
|
731
|
+
- **`note`** is optional: one line, at most 280 characters, saying what changed. **`by`** names the
|
|
732
|
+
resolver and is the publisher's to set (one line, at most 80; default "the publisher"); a signed-in
|
|
733
|
+
owner or admin is named by their first name. Never an email: a readers-visible page shows it.
|
|
734
|
+
- **Reopen** with `{ "entry": "<entryId>", "resolved": false }` (no note), by the same people.
|
|
735
|
+
- A reader cannot set `resolved` by writing a comment (the server refuses the field), and an edit to
|
|
736
|
+
a resolved comment keeps its resolution.
|
|
737
|
+
- 404 for a page or comment that is not there; 501 on a host with no `state`, or whose own
|
|
738
|
+
`StateStore` has no `setResolved` (`createStateStore` and `createMemoryStateStore` have it).
|
|
739
|
+
|
|
740
|
+
```ts
|
|
741
|
+
// app/api/artifacts/[id]/resolve/route.ts
|
|
742
|
+
import type { NextRequest } from 'next/server'
|
|
743
|
+
import { artifacts } from '@/lib/artifacts'
|
|
744
|
+
type Ctx = { params: Promise<{ id: string }> }
|
|
745
|
+
export function POST(request: NextRequest, ctx: Ctx) { return artifacts.RESOLVE(request, ctx) }
|
|
746
|
+
```
|
|
747
|
+
|
|
705
748
|
### The comments feed
|
|
706
749
|
|
|
707
750
|
So the publisher hears about a shared comment without opening every page, one route lists every
|
|
@@ -712,8 +755,8 @@ appear (the privacy test covers this handler like every other).
|
|
|
712
755
|
GET /api/comments?since=2026-09-28T12:00:00Z
|
|
713
756
|
Authorization: Bearer <publish key>
|
|
714
757
|
|
|
715
|
-
200 { now, comments: [ { artifactId, title, entryId, parent, name, quote, region,
|
|
716
|
-
|
|
758
|
+
200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name, quote, region,
|
|
759
|
+
body, transcript, audioUrl, at, resolved, link } ] }
|
|
717
760
|
```
|
|
718
761
|
|
|
719
762
|
Oldest first, at most 200, strictly after `since` (an ISO 8601 time with a zone; anything else is
|
|
@@ -725,6 +768,12 @@ parent's; a comment on an earlier version is highlighted in that list). A caller
|
|
|
725
768
|
at the last row's `at` and leaves it where it was when nothing came back; `now` is where a first
|
|
726
769
|
run starts, so it does not replay history.
|
|
727
770
|
|
|
771
|
+
`resolved` is the row's THREAD's resolution (`{ at, by, note? }`, a reply carries its parent's), or
|
|
772
|
+
null while the thread is open. **`&open=1` lists only open threads**: every row of a resolved thread
|
|
773
|
+
is left out, except a reply written after the thread was resolved, which is news about it. Filtered,
|
|
774
|
+
a page can come back short while more remains, so page with `cursor` (the `at` of the last comment
|
|
775
|
+
read, filtered or not) and `more` (a full page was read) rather than with the last row.
|
|
776
|
+
|
|
728
777
|
The path sits outside `/api/artifacts/` because `comments` is itself a legal page id there.
|
|
729
778
|
|
|
730
779
|
```ts
|
|
@@ -28,7 +28,31 @@ export type CommentValue = {
|
|
|
28
28
|
transcript?: (typeof COMMENT_TRANSCRIPTS)[number];
|
|
29
29
|
/** The name a reader typed so the owner can credit them. A signed-in reader's account name wins. */
|
|
30
30
|
by?: string;
|
|
31
|
+
/** On a thread's top-level comment only: the owner, an admin or the publisher marked the thread
|
|
32
|
+
* done (0.33.0). Written by the resolve route, never by a reader: `validateComment` refuses the
|
|
33
|
+
* key, and an edit keeps whatever the comment already carried. */
|
|
34
|
+
resolved?: CommentResolution;
|
|
31
35
|
};
|
|
36
|
+
/** A resolved thread: when, by whom (a first name, or what the publisher called itself, never an
|
|
37
|
+
* email, because a readers-visible page shows it to every reader), and an optional one-line note
|
|
38
|
+
* saying what changed. */
|
|
39
|
+
export type CommentResolution = {
|
|
40
|
+
at: string;
|
|
41
|
+
by: string;
|
|
42
|
+
note?: string;
|
|
43
|
+
};
|
|
44
|
+
export declare const MAX_RESOLVE_NOTE_CHARS = 280;
|
|
45
|
+
/** The resolve route's check on a note: absent, null or blank means no note; otherwise one line of
|
|
46
|
+
* at most MAX_RESOLVE_NOTE_CHARS, trimmed. */
|
|
47
|
+
export declare function cleanResolveNote(v: unknown): {
|
|
48
|
+
ok: true;
|
|
49
|
+
note: string | null;
|
|
50
|
+
} | {
|
|
51
|
+
ok: false;
|
|
52
|
+
error: string;
|
|
53
|
+
};
|
|
54
|
+
/** The resolution a stored value carries, or null. Tolerant of anything, since it reads stored JSON. */
|
|
55
|
+
export declare function resolutionOf(v: unknown): CommentResolution | null;
|
|
32
56
|
/** The server's check on a comment, called by the state POST for the `comments` slot. `existing`
|
|
33
57
|
* returns a comment THIS WRITER can see by its entry id, so a reply can only name a thread its
|
|
34
58
|
* writer was shown. The browser checks too; this is the one that counts. */
|
|
@@ -16,6 +16,29 @@ export function mergeCommentsState(state, comments, visible) {
|
|
|
16
16
|
export const MAX_COMMENT_CHARS = 4000;
|
|
17
17
|
export const MAX_COMMENT_NAME_CHARS = 80;
|
|
18
18
|
export const COMMENT_TRANSCRIPTS = ['browser', 'host', 'typed'];
|
|
19
|
+
export const MAX_RESOLVE_NOTE_CHARS = 280;
|
|
20
|
+
/** The resolve route's check on a note: absent, null or blank means no note; otherwise one line of
|
|
21
|
+
* at most MAX_RESOLVE_NOTE_CHARS, trimmed. */
|
|
22
|
+
export function cleanResolveNote(v) {
|
|
23
|
+
if (v === undefined || v === null)
|
|
24
|
+
return { ok: true, note: null };
|
|
25
|
+
if (typeof v !== 'string')
|
|
26
|
+
return { ok: false, error: 'note is one line of text, or null' };
|
|
27
|
+
const note = v.trim();
|
|
28
|
+
if (/[\r\n]/.test(note))
|
|
29
|
+
return { ok: false, error: 'note is one line' };
|
|
30
|
+
if (note.length > MAX_RESOLVE_NOTE_CHARS)
|
|
31
|
+
return { ok: false, error: `note is at most ${MAX_RESOLVE_NOTE_CHARS} characters` };
|
|
32
|
+
return { ok: true, note: note || null };
|
|
33
|
+
}
|
|
34
|
+
/** The resolution a stored value carries, or null. Tolerant of anything, since it reads stored JSON. */
|
|
35
|
+
export function resolutionOf(v) {
|
|
36
|
+
const r = v && typeof v === 'object' ? v.resolved : undefined;
|
|
37
|
+
if (!r || typeof r !== 'object' || typeof r.at !== 'string')
|
|
38
|
+
return null;
|
|
39
|
+
const { at, by, note } = r;
|
|
40
|
+
return { at, by: typeof by === 'string' ? by : '', ...(typeof note === 'string' && note ? { note } : {}) };
|
|
41
|
+
}
|
|
19
42
|
const ENTRY_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
20
43
|
// An uploaded asset path under the page (comments/<id>.webm), never a URL and never `..`.
|
|
21
44
|
const AUDIO_PATH = /^(?!.*\.\.)[A-Za-z0-9_-][A-Za-z0-9._/-]{0,299}$/;
|
|
@@ -67,6 +67,16 @@ export interface StateStore {
|
|
|
67
67
|
* feed reads it. Optional so a host's own store keeps working; without it the feed answers 501.
|
|
68
68
|
* The Firestore query needs a composite index on <base>State: slot ASC, at ASC. */
|
|
69
69
|
slotSince?(slot: string, after: string, limit: number): Promise<StateEntry[]>;
|
|
70
|
+
/** Set or clear `resolved` on one `many` entry's value, whoever wrote it (resolving a comment
|
|
71
|
+
* thread, 0.33.0). Keeps the entry's id, writer and time. Null when the entry is not on this page
|
|
72
|
+
* in this slot. Only the resolve route calls it, after its own check of who is asking. Optional
|
|
73
|
+
* so a host's own store keeps working; without it the resolve route answers 501. */
|
|
74
|
+
setResolved?(input: {
|
|
75
|
+
artifactId: string;
|
|
76
|
+
slot: string;
|
|
77
|
+
entryId: string;
|
|
78
|
+
resolved: unknown | null;
|
|
79
|
+
}): Promise<StateEntry | null>;
|
|
70
80
|
}
|
|
71
81
|
export declare const readerKeyFor: {
|
|
72
82
|
signedIn: (uid: string) => string;
|
|
@@ -21,6 +21,15 @@ export const readerKeyFor = {
|
|
|
21
21
|
const writerOf = (w) => ({
|
|
22
22
|
...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
|
|
23
23
|
});
|
|
24
|
+
/** The value with `resolved` set to the given resolution, or removed when it is null. */
|
|
25
|
+
const withResolved = (value, resolved) => {
|
|
26
|
+
const base = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
|
27
|
+
if (resolved === null)
|
|
28
|
+
delete base.resolved;
|
|
29
|
+
else
|
|
30
|
+
base.resolved = structuredClone(resolved);
|
|
31
|
+
return base;
|
|
32
|
+
};
|
|
24
33
|
const oneId = (artifactId, slot, readerKey) => `${artifactId}__${slot}__${readerKey}`;
|
|
25
34
|
const toEntry = (id, d) => {
|
|
26
35
|
const { json, ...rest } = d;
|
|
@@ -105,6 +114,18 @@ export function createStateStore(db, base) {
|
|
|
105
114
|
// order on at. Needs the composite index (slot ASC, at ASC) on <base>State, or Firestore
|
|
106
115
|
// refuses the query with FAILED_PRECONDITION and a link that creates it.
|
|
107
116
|
slotSince: (slot, after, limit) => load(col().where('slot', '==', slot).where('at', '>', after).orderBy('at').limit(limit)),
|
|
117
|
+
async setResolved({ artifactId, slot, entryId, resolved }) {
|
|
118
|
+
const ref = col().doc(entryId);
|
|
119
|
+
const snap = await ref.get();
|
|
120
|
+
if (!snap.exists)
|
|
121
|
+
return null;
|
|
122
|
+
const e = toEntry(snap.id, snap.data());
|
|
123
|
+
if (e.artifactId !== artifactId || e.slot !== slot || e.shape !== 'many')
|
|
124
|
+
return null;
|
|
125
|
+
const value = withResolved(e.value, resolved);
|
|
126
|
+
await ref.update({ json: JSON.stringify(value) });
|
|
127
|
+
return { ...e, value };
|
|
128
|
+
},
|
|
108
129
|
};
|
|
109
130
|
return store;
|
|
110
131
|
}
|
|
@@ -172,6 +193,14 @@ export function createMemoryStateStore() {
|
|
|
172
193
|
docs.set(entryId, next);
|
|
173
194
|
return structuredClone(next);
|
|
174
195
|
},
|
|
196
|
+
async setResolved({ artifactId, slot, entryId, resolved }) {
|
|
197
|
+
const e = docs.get(entryId);
|
|
198
|
+
if (!e || e.artifactId !== artifactId || e.slot !== slot || e.shape !== 'many')
|
|
199
|
+
return null;
|
|
200
|
+
const next = { ...e, value: withResolved(e.value, resolved) };
|
|
201
|
+
docs.set(entryId, next);
|
|
202
|
+
return structuredClone(next);
|
|
203
|
+
},
|
|
175
204
|
slotSince: async (slot, after, limit) => [...docs.values()]
|
|
176
205
|
.filter((e) => e.slot === slot && e.at > after)
|
|
177
206
|
.sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { type CommentsMode } from '../artifacts/comments.js';
|
|
1
|
+
import { type CommentResolution, type CommentsMode } from '../artifacts/comments.js';
|
|
2
2
|
import { type VoiceHost } from './voice-recorder.js';
|
|
3
3
|
import { type DeviceAudio } from './device-audio.js';
|
|
4
|
+
/** The line a resolved thread collapses to. */
|
|
5
|
+
export declare const resolvedLine: (r: CommentResolution) => string;
|
|
4
6
|
/** A finger rather than a mouse: the comment button then goes where the OS menu is not. */
|
|
5
7
|
export declare function onTouch(): boolean;
|
|
6
8
|
export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }: {
|
|
@@ -20,13 +20,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
20
20
|
// on load and on every hashchange the pin is scrolled into view and its thread opened; a reply's
|
|
21
21
|
// id opens its parent's thread; a comment on an earlier version is scrolled to and highlighted.
|
|
22
22
|
//
|
|
23
|
+
// A RESOLVED thread (0.33.0: the owner, an admin or the publisher marked it done) draws a muted
|
|
24
|
+
// check pin with no box, so the page reads clean, and opens collapsed to one line, "Resolved: <note>",
|
|
25
|
+
// with the comment and its replies a tap away. The Comments count is open threads only. The owner
|
|
26
|
+
// and admins get Resolve and Reopen in every shared thread.
|
|
27
|
+
//
|
|
23
28
|
// Rendered empty on the server and on the first client render so the markup always hydrates; the
|
|
24
29
|
// overlay is portalled onto document.body so its coordinates are page coordinates.
|
|
25
30
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
26
31
|
import { createPortal } from 'react-dom';
|
|
27
32
|
import { PINS_SET, announcePins, signInBack } from './page-bridge.js';
|
|
28
33
|
import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
|
|
29
|
-
import { COMMENTS_SLOT } from '../artifacts/comments.js';
|
|
34
|
+
import { COMMENTS_SLOT, MAX_RESOLVE_NOTE_CHARS, resolutionOf } from '../artifacts/comments.js';
|
|
30
35
|
import { CommentCard } from './comment-card.js';
|
|
31
36
|
import { AudioPlay, hostVoice, newMemoId } from './voice-recorder.js';
|
|
32
37
|
import { deviceAudio as defaultDeviceAudio, isDeviceAudio } from './device-audio.js';
|
|
@@ -40,6 +45,9 @@ const LONG_PRESS_MS = 400;
|
|
|
40
45
|
const HASH = /^#comment-([A-Za-z0-9_-]{1,128})$/;
|
|
41
46
|
/** The entry id a `#comment-<id>` hash names, or null. */
|
|
42
47
|
const commentHashId = (hash) => HASH.exec(hash)?.[1] ?? null;
|
|
48
|
+
/** The line a resolved thread collapses to. */
|
|
49
|
+
export const resolvedLine = (r) => (r.note ? `Resolved: ${r.note}` : 'Resolved');
|
|
50
|
+
const resolvedOf = (item) => (item.kind === 'shared' ? resolutionOf(item.value) : null);
|
|
43
51
|
const DRAG_PX = 8;
|
|
44
52
|
/** Where a drag never starts a box: controls, and anything that is somebody's words. */
|
|
45
53
|
const NOT_FOR_BOXES = 'a, button, input, textarea, select, audio, video[controls], summary, label, [data-comment-ui], [data-note-toggle], [data-heading-notes], [data-artifact-notes], .artifact-defined-term, .artifact-word';
|
|
@@ -83,6 +91,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
83
91
|
const host = useMemo(() => voice ?? hostVoice(artifactId), [voice, artifactId]);
|
|
84
92
|
const audioDb = useMemo(() => audioStore ?? defaultDeviceAudio, [audioStore]);
|
|
85
93
|
const stateUrl = `/api/artifacts/${artifactId}/state`;
|
|
94
|
+
const resolveUrl = `/api/artifacts/${artifactId}/resolve`;
|
|
86
95
|
const personalUrl = `/api/artifacts/${artifactId}/personal`;
|
|
87
96
|
const [mounted, setMounted] = useState(false);
|
|
88
97
|
const [shared, setShared] = useState([]);
|
|
@@ -105,6 +114,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
105
114
|
const [highlight, setHighlight] = useState(null);
|
|
106
115
|
/** A selector to scroll into view once the render that draws it has committed. */
|
|
107
116
|
const [scrollTo, setScrollTo] = useState(null);
|
|
117
|
+
/** A resolved thread the address named, opened expanded rather than collapsed. */
|
|
118
|
+
const [expanded, setExpanded] = useState(null);
|
|
108
119
|
const call = useCallback(async (url, init) => {
|
|
109
120
|
try {
|
|
110
121
|
const r = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...init });
|
|
@@ -379,6 +390,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
379
390
|
setShowPins(true);
|
|
380
391
|
setHighlight(null);
|
|
381
392
|
setThread(id);
|
|
393
|
+
setExpanded(id);
|
|
382
394
|
setScrollTo(`[data-comment-pin][data-comment-id="${id}"]`);
|
|
383
395
|
}
|
|
384
396
|
else if (layout.earlier.some((i) => i.id === id)) {
|
|
@@ -461,7 +473,9 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
461
473
|
setThread(null);
|
|
462
474
|
};
|
|
463
475
|
const edit = async (item, body) => {
|
|
464
|
-
|
|
476
|
+
// `resolved` is the owner's to set, and the server keeps it through an edit; sending it back is refused.
|
|
477
|
+
const { resolved: _resolved, ...rest } = item.value;
|
|
478
|
+
const value = { ...rest, body };
|
|
465
479
|
if (item.where === 'device') {
|
|
466
480
|
replaceDeviceNote(artifactId, item.id, value);
|
|
467
481
|
refreshDevice();
|
|
@@ -486,19 +500,29 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
486
500
|
setShared(sharedFrom(r.body));
|
|
487
501
|
return null;
|
|
488
502
|
};
|
|
503
|
+
const resolveThread = async (item, resolved, note) => {
|
|
504
|
+
const r = await call(resolveUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ entry: item.id, resolved, ...(note ? { note } : {}) }) });
|
|
505
|
+
if (r.status !== 200)
|
|
506
|
+
return typeof r.body.error === 'string' ? r.body.error : 'that did not save; try again';
|
|
507
|
+
const s = await call(stateUrl);
|
|
508
|
+
if (s.status === 200)
|
|
509
|
+
setShared(sharedFrom(s.body));
|
|
510
|
+
return null;
|
|
511
|
+
};
|
|
489
512
|
const open = layout.placed.find((p) => p.item.id === thread);
|
|
490
513
|
const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
|
|
491
|
-
const count = tops.length;
|
|
492
|
-
const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [missing ? (_jsxs("div", { "data-comment-missing": true, role: "status", className: "fixed inset-x-3 top-3 z-50 mx-auto max-w-sm rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-4 font-sans text-sm leading-snug text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsx("p", { className: "m-0 flex-1", children: "That comment can't be shown to you. Comments on this page are visible to its owner and admins." }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setMissing(false), className: "-mr-1 -mt-1 px-1 text-lg leading-none opacity-60", children: "\u00D7" })] }), !signedIn && signIn ? (_jsx("a", { "data-comment-missing-sign-in": true, href: signInBack(signIn), className: "mt-3 inline-block rounded-full px-4 py-1.5 font-medium no-underline", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: "Sign in" })) : null] })) : null, showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (
|
|
514
|
+
const count = tops.filter((i) => !resolvedOf(i)).length;
|
|
515
|
+
const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [missing ? (_jsxs("div", { "data-comment-missing": true, role: "status", className: "fixed inset-x-3 top-3 z-50 mx-auto max-w-sm rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-4 font-sans text-sm leading-snug text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsx("p", { className: "m-0 flex-1", children: "That comment can't be shown to you. Comments on this page are visible to its owner and admins." }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setMissing(false), className: "-mr-1 -mt-1 px-1 text-lg leading-none opacity-60", children: "\u00D7" })] }), !signedIn && signIn ? (_jsx("a", { "data-comment-missing-sign-in": true, href: signInBack(signIn), className: "mt-3 inline-block rounded-full px-4 py-1.5 font-medium no-underline", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: "Sign in" })) : null] })) : null, showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box && !resolvedOf(item) ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (
|
|
493
516
|
// On a phone the button sits at the TOP of the screen, never beside the selection: that is
|
|
494
517
|
// where iOS draws its own Copy / Look Up menu, and the two fought for the same tap (Gary,
|
|
495
518
|
// 2026-09-29: "I keep trying to fight with the Apple pop-up"). A mouse keeps it by the text.
|
|
496
|
-
_jsx("button", { type: "button", "data-comment-chip": true, "data-comment-chip-at": onTouch() ? 'top' : 'selection', className: onTouch() ? 'fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-5 py-2 text-sm font-medium shadow-lg' : 'absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg', style: onTouch() ? { top: 'calc(env(safe-area-inset-top, 0px) + 12px)', background: accent, color: 'var(--a-on-accent, #111)' } : { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo, by) => saveNew(body, share, draft, memo, by), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
|
|
497
|
-
return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { "data-comment-earlier-id": item.id, ...(highlight === item.id ? { 'data-comment-highlight': '' } : {}), className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', ...(highlight === item.id ? { background: `color-mix(in srgb, ${accent} 14%, transparent)` } : {}) }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
|
|
519
|
+
_jsx("button", { type: "button", "data-comment-chip": true, "data-comment-chip-at": onTouch() ? 'top' : 'selection', className: onTouch() ? 'fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-5 py-2 text-sm font-medium shadow-lg' : 'absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg', style: onTouch() ? { top: 'calc(env(safe-area-inset-top, 0px) + 12px)', background: accent, color: 'var(--a-on-accent, #111)' } : { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo, by) => saveNew(body, share, draft, memo, by), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, canResolve: openItem.kind === 'shared' && isOwner, startExpanded: expanded === openItem.id, accent: accent, audioDb: audioDb, onClose: () => { setThread(null); setExpanded(null); }, onRemove: remove, onEdit: edit, onReply: reply, onResolve: resolveThread }, openItem.id) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
|
|
520
|
+
return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { "data-comment-earlier-id": item.id, ...(highlight === item.id ? { 'data-comment-highlight': '' } : {}), className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', ...(highlight === item.id ? { background: `color-mix(in srgb, ${accent} 14%, transparent)` } : {}) }, children: [resolvedOf(item) ? _jsxs("span", { "data-comment-resolved-line": true, className: "block text-[13px] font-medium", style: { color: accent }, children: ["\u2713 ", resolvedLine(resolvedOf(item))] }) : null, item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
|
|
498
521
|
}
|
|
499
522
|
function Pin({ item, left, top, accent, ground, onOpen }) {
|
|
500
523
|
const personal = item.kind === 'personal';
|
|
501
|
-
|
|
524
|
+
const resolved = resolvedOf(item);
|
|
525
|
+
return (_jsx("button", { type: "button", "data-comment-pin": item.kind, "data-comment-id": item.id, onClick: onOpen, ...(resolved ? { 'data-comment-resolved': '' } : {}), "aria-label": personal ? 'Your personal note (only you)' : resolved ? `${resolvedLine(resolved)}. Comment by ${item.name}` : `Comment by ${item.name}`, className: "absolute z-30 flex h-6 min-w-6 items-center justify-center gap-1 rounded-full border-2 px-1.5 text-[10px] font-semibold shadow", style: { left, top, borderColor: accent, background: personal || resolved ? ground : accent, color: personal || resolved ? accent : 'var(--a-on-accent, #111)', ...(resolved ? { opacity: 0.45 } : {}) }, children: personal ? _jsx("span", { children: "only you" }) : resolved ? _jsx("span", { "aria-hidden": true, children: "\u2713" }) : _jsx("span", { "aria-hidden": true, children: "\u25CF" }) }));
|
|
502
526
|
}
|
|
503
527
|
function Floating({ x, y, children }) {
|
|
504
528
|
// Kept inside the screen on a phone, where the margin a pin sits in may be the screen's edge.
|
|
@@ -506,12 +530,25 @@ function Floating({ x, y, children }) {
|
|
|
506
530
|
const left = typeof window === 'undefined' ? x : Math.max(window.scrollX + 8, Math.min(x, window.scrollX + window.innerWidth - width - 8));
|
|
507
531
|
return _jsx("div", { className: "absolute z-50", style: { left, top: y }, children: children });
|
|
508
532
|
}
|
|
509
|
-
function Thread({ item, replies, canReply, accent, audioDb, onClose, onRemove, onEdit, onReply }) {
|
|
533
|
+
function Thread({ item, replies, canReply, canResolve = false, startExpanded = false, accent, audioDb, onClose, onRemove, onEdit, onReply, onResolve }) {
|
|
510
534
|
const [replying, setReplying] = useState(false);
|
|
511
|
-
|
|
535
|
+
const [resolving, setResolving] = useState(false);
|
|
536
|
+
const [error, setError] = useState(null);
|
|
537
|
+
const [showResolved, setShowResolved] = useState(startExpanded);
|
|
538
|
+
const resolved = resolvedOf(item);
|
|
539
|
+
const shell = 'w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl';
|
|
540
|
+
const reopen = canResolve && onResolve ? (_jsx("button", { type: "button", "data-comment-reopen": true, className: "ml-3 text-xs underline", onClick: async () => setError(await onResolve(item, false)), children: "Reopen" })) : null;
|
|
541
|
+
if (resolved && !showResolved) {
|
|
542
|
+
return (_jsxs("div", { "data-comment-thread": true, "data-comment-thread-resolved": true, "data-comment-ui": true, "data-nospeak": true, className: shell, children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsxs("p", { "data-comment-resolved-line": true, className: "m-0 flex-1 text-[14px]", children: [_jsx("span", { "aria-hidden": true, style: { color: accent }, children: "\u2713 " }), resolvedLine(resolved)] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), _jsxs("p", { className: "m-0 mt-1 text-xs opacity-60", children: [resolved.by ? `by ${resolved.by}` : null, _jsxs("button", { type: "button", "data-comment-expand": true, className: resolved.by ? 'ml-3 underline' : 'underline', onClick: () => setShowResolved(true), children: ["Show comment", replies.length ? ` and ${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}` : ''] }), reopen] }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null] }));
|
|
543
|
+
}
|
|
544
|
+
return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: shell, children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : resolved ? 'Resolved comment' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), resolved ? _jsxs("p", { "data-comment-resolved-line": true, className: "m-0 mb-2 text-[13px]", style: { color: accent }, children: ["\u2713 ", resolvedLine(resolved), resolved.by ? _jsx("span", { className: "opacity-70", children: ` · ${resolved.by}` }) : null] }) : null, item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
|
|
512
545
|
? _jsx(InlineForm, { label: "Reply", accent: accent, onCancel: () => setReplying(false), onSubmit: async (b) => { const e = await onReply(item, b); if (!e)
|
|
513
546
|
setReplying(false); return e; } })
|
|
514
|
-
: _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null
|
|
547
|
+
: _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null, canResolve && onResolve && !replying ? (resolved ? _jsx("p", { className: "m-0 mt-2", children: reopen })
|
|
548
|
+
: resolving
|
|
549
|
+
? _jsx(InlineForm, { label: "Resolve", accent: accent, optional: true, placeholder: "What changed (optional)", maxLength: MAX_RESOLVE_NOTE_CHARS, onCancel: () => setResolving(false), onSubmit: async (note) => { const e = await onResolve(item, true, note || undefined); if (!e)
|
|
550
|
+
setResolving(false); return e; } })
|
|
551
|
+
: _jsx("button", { type: "button", "data-comment-resolve": true, onClick: () => setResolving(true), className: canReply ? 'ml-3 mt-2 text-xs underline' : 'mt-2 text-xs underline', children: "Resolve" })) : null, error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null] }));
|
|
515
552
|
}
|
|
516
553
|
/** Where a comment's recording plays from: its signed URL, or this browser's copy of a device note's. */
|
|
517
554
|
function useAudioSrc(item, audioDb) {
|
|
@@ -539,9 +576,9 @@ function Entry({ item, accent, audioDb, onRemove, onEdit }) {
|
|
|
539
576
|
setEditing(false); return e; } });
|
|
540
577
|
return (_jsxs("div", { children: [src ? _jsx(AudioPlay, { src: src, accent: accent }) : null, item.value.body ? _jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }) : null, _jsxs("p", { className: "m-0 text-xs opacity-60", children: [item.kind === 'personal' ? (item.where === 'device' ? 'only you, on this device' : 'only you') : item.name, item.mine ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-comment-edit": true, onClick: () => setEditing(true), className: "ml-2 underline", children: "edit" }), _jsx("button", { type: "button", "data-comment-delete": true, onClick: () => void onRemove(item), className: "ml-2 underline", children: "delete" })] })) : null] })] }));
|
|
541
578
|
}
|
|
542
|
-
function InlineForm({ label, initial = '', accent = 'currentColor', onSubmit, onCancel }) {
|
|
579
|
+
function InlineForm({ label, initial = '', accent = 'currentColor', optional = false, placeholder, maxLength, onSubmit, onCancel }) {
|
|
543
580
|
const [text, setText] = useState(initial);
|
|
544
581
|
const [error, setError] = useState(null);
|
|
545
|
-
return (_jsxs("form", { className: "mt-2", onSubmit: async (e) => { e.preventDefault(); if (text.trim())
|
|
546
|
-
setError(await onSubmit(text.trim())); }, children: [_jsx("textarea", { value: text, autoFocus: true, rows: 2, onChange: (e) => setText(e.target.value), "aria-label": label, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-transparent p-2", style: NO_ZOOM_FONT }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-1 flex gap-3 text-xs", children: [_jsx("button", { type: "submit", disabled: !text.trim(), className: "rounded-full px-3 py-0.5 font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: label }), _jsx("button", { type: "button", onClick: onCancel, className: "opacity-70", children: "Cancel" })] })] }));
|
|
582
|
+
return (_jsxs("form", { className: "mt-2", onSubmit: async (e) => { e.preventDefault(); if (optional || text.trim())
|
|
583
|
+
setError(await onSubmit(text.trim())); }, children: [_jsx("textarea", { value: text, autoFocus: true, rows: 2, onChange: (e) => setText(optional ? e.target.value.replace(/[\r\n]+/g, ' ') : e.target.value), "aria-label": optional && placeholder ? placeholder : label, placeholder: placeholder, maxLength: maxLength, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-transparent p-2", style: NO_ZOOM_FONT }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-1 flex gap-3 text-xs", children: [_jsx("button", { type: "submit", disabled: !optional && !text.trim(), className: "rounded-full px-3 py-0.5 font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: label }), _jsx("button", { type: "button", onClick: onCancel, className: "opacity-70", children: "Cancel" })] })] }));
|
|
547
584
|
}
|
|
@@ -156,6 +156,11 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
156
156
|
}>;
|
|
157
157
|
}) => Promise<NextResponse<unknown>>;
|
|
158
158
|
COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
|
|
159
|
+
RESOLVE: (req: NextRequest, { params }: {
|
|
160
|
+
params: Promise<{
|
|
161
|
+
id: string;
|
|
162
|
+
}>;
|
|
163
|
+
}) => Promise<NextResponse<unknown>>;
|
|
159
164
|
Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
|
|
160
165
|
generateMetadata: ({ params }: Params) => Promise<Metadata>;
|
|
161
166
|
VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
|
|
@@ -50,5 +50,6 @@ export declare function createStateRoutes(ctx: StateRoutesContext): {
|
|
|
50
50
|
STATE_POST: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
|
|
51
51
|
RESPONSES: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
52
52
|
COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
|
|
53
|
+
RESOLVE: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
53
54
|
};
|
|
54
55
|
export {};
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// GET /api/artifacts/<id>/responses publish key; every answer with who (?format=csv)
|
|
6
6
|
// DELETE /api/artifacts/<id>/responses?reader=<key> publish key; one reader's answers
|
|
7
7
|
// GET /api/comments?since=<ISO> publish key; every shared comment on any page after a time
|
|
8
|
+
// (&open=1: leave out threads that are resolved)
|
|
9
|
+
// POST /api/artifacts/<id>/resolve publish key, or the owner or an admin signed in; resolve or
|
|
10
|
+
// reopen one comment thread { entry, resolved?, note?, by? }
|
|
8
11
|
//
|
|
9
12
|
// Who is writing: the grant a gated page already uses, or on a page with `writers: anyone`, an
|
|
10
13
|
// anonymous id in an HttpOnly cookie. When both are present the anonymous answers move to the
|
|
@@ -16,7 +19,7 @@ import { ARTIFACT_ID_RE } from './ids.js';
|
|
|
16
19
|
import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
|
|
17
20
|
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
18
21
|
import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters, slotVisibility } from '../artifacts/state.js';
|
|
19
|
-
import { COMMENTS_SLOT, validateComment } from '../artifacts/comments.js';
|
|
22
|
+
import { COMMENTS_SLOT, MAX_COMMENT_NAME_CHARS, cleanResolveNote, resolutionOf, validateComment } from '../artifacts/comments.js';
|
|
20
23
|
import { isCommentAudioPath } from '../artifacts/audio.js';
|
|
21
24
|
import { readerKeyFor } from '../artifacts/state-store.js';
|
|
22
25
|
import { NOTES_SLOT, checkNoteValue, checklistAnswerIds, hasNotesWidget } from '../artifacts/widgets.js';
|
|
@@ -24,6 +27,7 @@ import { CHECKLIST_SLOT, checkChecklistValue } from '../artifacts/checklist.js';
|
|
|
24
27
|
import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
|
|
25
28
|
export const ANON_COOKIE = 'artifact_anon';
|
|
26
29
|
const ANON_ID = /^[A-Za-z0-9]{24}$/;
|
|
30
|
+
const ENTRY_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
27
31
|
const MAX_BODY = 16 * 1024;
|
|
28
32
|
/** The most comments one feed answer carries. A caller that gets this many moves its cursor to the
|
|
29
33
|
* last row's `at` and asks again. */
|
|
@@ -266,6 +270,10 @@ export function createStateRoutes(ctx) {
|
|
|
266
270
|
if (op === 'replace' && seen.has(entryId) && (seen.get(entryId).parent ?? null) !== (c.value.parent ?? null))
|
|
267
271
|
return json({ error: 'an edit cannot move a comment into or out of a thread' }, 400);
|
|
268
272
|
value = c.value;
|
|
273
|
+
// Resolving is the owner's, never the writer's: an edit keeps the thread as it was.
|
|
274
|
+
const prev = op === 'replace' ? resolutionOf(seen.get(entryId)) : null;
|
|
275
|
+
if (prev)
|
|
276
|
+
value = { ...c.value, resolved: prev };
|
|
269
277
|
}
|
|
270
278
|
if (op === 'replace') {
|
|
271
279
|
const r = await ctx.state.replace({ artifactId: id, slot, readerKey: writer.key, entryId: entryId, value });
|
|
@@ -321,13 +329,23 @@ export function createStateRoutes(ctx) {
|
|
|
321
329
|
/** GET /api/comments?since=<ISO>: every SHARED comment on any of this host's pages written after
|
|
322
330
|
* `since` (exclusive), oldest first, at most FEED_MAX, each with a link that opens it on the
|
|
323
331
|
* page. It reads the `comments` slot of the state core only; personal notes live in a store no
|
|
324
|
-
* publisher route reads, so they cannot appear here. The publisher's own notifier polls it.
|
|
332
|
+
* publisher route reads, so they cannot appear here. The publisher's own notifier polls it.
|
|
333
|
+
*
|
|
334
|
+
* Each row carries `resolved`: its THREAD's resolution (a reply carries its parent's), or null.
|
|
335
|
+
* `&open=1` leaves out every row of a resolved thread, except a reply written after the thread
|
|
336
|
+
* was resolved, which is news about it. `cursor` is the `at` of the last comment READ, filtered
|
|
337
|
+
* or not, and `more` says a full page was read; a caller paging with `open=1` follows `cursor`,
|
|
338
|
+
* since the last row it was given can sit well before it. */
|
|
325
339
|
async function COMMENTS_FEED(req) {
|
|
326
340
|
if (!isPublisherAuthed(req, ctx))
|
|
327
341
|
return json({ error: 'Unauthorized' }, 401);
|
|
328
342
|
const since = feedSince(req.nextUrl.searchParams.get('since'));
|
|
329
343
|
if (!since)
|
|
330
344
|
return json({ error: 'since must be an ISO 8601 time with a zone, like 2026-09-28T12:00:00Z' }, 400);
|
|
345
|
+
const openParam = req.nextUrl.searchParams.get('open');
|
|
346
|
+
if (openParam !== null && !['1', 'true', '0', 'false'].includes(openParam))
|
|
347
|
+
return json({ error: 'open is 1 or 0' }, 400);
|
|
348
|
+
const openOnly = openParam === '1' || openParam === 'true';
|
|
331
349
|
if (!ctx.state)
|
|
332
350
|
return json({ error: 'this host keeps no answers' }, 501);
|
|
333
351
|
if (!ctx.state.slotSince)
|
|
@@ -337,6 +355,14 @@ export function createStateRoutes(ctx) {
|
|
|
337
355
|
const pages = new Map();
|
|
338
356
|
const pageOf = (id) => { if (!pages.has(id))
|
|
339
357
|
pages.set(id, ctx.store.get(id)); return pages.get(id); };
|
|
358
|
+
// A reply's thread is resolved on its parent, which may sit outside this window: read the page's
|
|
359
|
+
// comments once, and only for a page that has a reply in it.
|
|
360
|
+
const threads = new Map();
|
|
361
|
+
const commentsOf = (id) => {
|
|
362
|
+
if (!threads.has(id))
|
|
363
|
+
threads.set(id, ctx.state.entries(id).then((all) => new Map(all.filter((e) => e.slot === COMMENTS_SLOT).map((e) => [e.id, e]))));
|
|
364
|
+
return threads.get(id);
|
|
365
|
+
};
|
|
340
366
|
const comments = [];
|
|
341
367
|
for (const e of entries) {
|
|
342
368
|
const a = await pageOf(e.artifactId);
|
|
@@ -344,16 +370,98 @@ export function createStateRoutes(ctx) {
|
|
|
344
370
|
if (!a)
|
|
345
371
|
continue;
|
|
346
372
|
const v = e.value;
|
|
373
|
+
const resolved = resolutionOf(v.parent ? (await commentsOf(e.artifactId)).get(v.parent)?.value : v);
|
|
374
|
+
if (openOnly && resolved && !(v.parent && e.at > resolved.at))
|
|
375
|
+
continue;
|
|
347
376
|
const audio = isCommentAudioPath(v.audio) && ctx.audioUrl ? await ctx.audioUrl(e.artifactId, v.audio) : null;
|
|
348
377
|
comments.push({
|
|
349
378
|
artifactId: e.artifactId, title: a.title, entryId: e.id, parent: v.parent ?? null,
|
|
350
379
|
name: e.writer.anonymous ? null : e.writer.name?.trim() || 'a signed-in reader',
|
|
351
380
|
quote: v.anchor?.kind === 'text' ? v.anchor.quote : null, region: v.anchor?.kind === 'region',
|
|
352
|
-
body: v.body, transcript: v.transcript ?? null, audioUrl: audio, at: e.at,
|
|
381
|
+
body: v.body, transcript: v.transcript ?? null, audioUrl: audio, at: e.at, resolved,
|
|
353
382
|
link: `${ctx.pageUrl(e.artifactId)}#comment-${e.id}`,
|
|
354
383
|
});
|
|
355
384
|
}
|
|
356
|
-
return json({ now, comments });
|
|
385
|
+
return json({ now, comments, cursor: entries.length ? entries[entries.length - 1].at : null, more: entries.length >= FEED_MAX });
|
|
386
|
+
}
|
|
387
|
+
/** POST /api/artifacts/<id>/resolve: mark one comment thread done, or open it again (0.33.0).
|
|
388
|
+
* Body { entry, resolved?: true|false (default true), note?: one line, at most 280, by?: one line }.
|
|
389
|
+
* `entry` may name a reply: the thread it belongs to is what is resolved, and the answer's
|
|
390
|
+
* `entry` names that thread's top comment.
|
|
391
|
+
*
|
|
392
|
+
* Who: the publish key or a publisher pass (an agent on the operator's machine resolves with the
|
|
393
|
+
* credential it publishes with, and may say who it is with `by`), or a reader signed in as the
|
|
394
|
+
* page's owner or one of this hub's admins (named by their first name; `by` is ignored), from
|
|
395
|
+
* this host's own pages as JSON. Everyone else is refused before the page is looked up. */
|
|
396
|
+
async function RESOLVE(req, { params }) {
|
|
397
|
+
const publisher = isPublisherAuthed(req, ctx);
|
|
398
|
+
let by = 'the publisher';
|
|
399
|
+
if (!publisher) {
|
|
400
|
+
const reader = verifyGrant(ctx.readerSecret(), req.cookies.get(GRANT_COOKIE)?.value);
|
|
401
|
+
if (!reader)
|
|
402
|
+
return json({ error: 'sign in as the page\'s owner, or send the publish key' }, 401);
|
|
403
|
+
if (!isOwner(reader))
|
|
404
|
+
return json({ error: 'only the page\'s owner, this hub\'s admins and the publisher resolve comments' }, 403);
|
|
405
|
+
const origin = req.headers.get('origin');
|
|
406
|
+
const site = req.headers.get('sec-fetch-site');
|
|
407
|
+
if ((origin !== null && origin !== siteOrigin) || (site !== null && site !== 'same-origin'))
|
|
408
|
+
return json({ error: 'wrong origin' }, 403);
|
|
409
|
+
if (!/^application\/json\b/i.test(req.headers.get('content-type') ?? ''))
|
|
410
|
+
return json({ error: 'send JSON' }, 415);
|
|
411
|
+
by = reader.name?.trim().split(/\s+/)[0] || 'the owner';
|
|
412
|
+
}
|
|
413
|
+
const raw = await req.text();
|
|
414
|
+
if (Buffer.byteLength(raw, 'utf8') > 4096)
|
|
415
|
+
return json({ error: 'request over 4 KB' }, 413);
|
|
416
|
+
let b;
|
|
417
|
+
try {
|
|
418
|
+
const parsed = JSON.parse(raw);
|
|
419
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
420
|
+
return json({ error: 'body must be a JSON object' }, 400);
|
|
421
|
+
b = parsed;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return json({ error: 'body must be JSON' }, 400);
|
|
425
|
+
}
|
|
426
|
+
const unknownKeys = Object.keys(b).filter((k) => !['entry', 'resolved', 'note', 'by'].includes(k));
|
|
427
|
+
if (unknownKeys.length)
|
|
428
|
+
return json({ error: `unknown field: ${unknownKeys.join(', ')}; the body is { entry, resolved?, note?, by? }` }, 400);
|
|
429
|
+
if (typeof b.entry !== 'string' || !ENTRY_ID.test(b.entry))
|
|
430
|
+
return json({ error: 'entry is the id of the comment to resolve' }, 400);
|
|
431
|
+
if (b.resolved !== undefined && typeof b.resolved !== 'boolean')
|
|
432
|
+
return json({ error: 'resolved is true or false' }, 400);
|
|
433
|
+
const resolve = b.resolved !== false;
|
|
434
|
+
const note = cleanResolveNote(b.note);
|
|
435
|
+
if (!note.ok)
|
|
436
|
+
return json({ error: note.error }, 400);
|
|
437
|
+
if (!resolve && note.note)
|
|
438
|
+
return json({ error: 'reopening a thread takes no note' }, 400);
|
|
439
|
+
if (publisher && b.by !== undefined) {
|
|
440
|
+
if (typeof b.by !== 'string' || /[\r\n]/.test(b.by) || !b.by.trim() || b.by.trim().length > MAX_COMMENT_NAME_CHARS)
|
|
441
|
+
return json({ error: `by is one line of at most ${MAX_COMMENT_NAME_CHARS} characters` }, 400);
|
|
442
|
+
by = b.by.trim();
|
|
443
|
+
}
|
|
444
|
+
if (!ctx.state)
|
|
445
|
+
return json({ error: 'this host keeps no answers' }, 501);
|
|
446
|
+
if (!ctx.state.setResolved)
|
|
447
|
+
return json({ error: 'this host cannot resolve comments' }, 501);
|
|
448
|
+
const { id } = await params;
|
|
449
|
+
const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
|
|
450
|
+
if (!a)
|
|
451
|
+
return json({ error: `no artifact with id ${id}` }, 404);
|
|
452
|
+
const all = new Map((await ctx.state.entries(id)).filter((e) => e.slot === COMMENTS_SLOT).map((e) => [e.id, e]));
|
|
453
|
+
const asked = all.get(b.entry);
|
|
454
|
+
if (!asked)
|
|
455
|
+
return json({ error: 'no comment with that id on this page' }, 404);
|
|
456
|
+
const parent = asked.value.parent;
|
|
457
|
+
const root = parent ? all.get(parent) : asked;
|
|
458
|
+
if (!root)
|
|
459
|
+
return json({ error: 'the comment this reply answers is gone' }, 404);
|
|
460
|
+
const resolution = resolve ? { at: new Date().toISOString(), by, ...(note.note ? { note: note.note } : {}) } : null;
|
|
461
|
+
const r = await ctx.state.setResolved({ artifactId: id, slot: COMMENTS_SLOT, entryId: root.id, resolved: resolution });
|
|
462
|
+
if (!r)
|
|
463
|
+
return json({ error: 'no comment with that id on this page' }, 404);
|
|
464
|
+
return json({ id, entry: root.id, resolved: resolution });
|
|
357
465
|
}
|
|
358
|
-
return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED };
|
|
466
|
+
return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED, RESOLVE };
|
|
359
467
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|