@supersuit/artifacts 0.30.2 → 0.31.1

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 CHANGED
@@ -3,6 +3,62 @@
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.31.1
7
+
8
+ **Play from here stays inside the page card, not only the screen.** 0.30.2 kept the bar inside
9
+ the screen, but the company host draws the page in a rounded card that clips its content, so the
10
+ bar's right end was still cut off at the card's edge.
11
+
12
+ - The bar is bounded by every ancestor that clips (any overflow other than visible) as well as the
13
+ screen.
14
+ - DETECTOR: at iPhone width on the company host, pause and tap a word at the right edge. A pill
15
+ whose right end is cut off means 0.30.2.
16
+ - REMEDY: update the dependency.
17
+ - Tests: `artifact-reader-controls.test.tsx` (a clipping card 10 to 380px keeps the bar's right edge
18
+ at 372px or less).
19
+
20
+ ## 0.31.0
21
+
22
+ **The owner and admins change a page's share and comment settings from the page menu, and a later
23
+ publish of the same file no longer undoes it.** Release 2 of the page menu design. Until now these
24
+ settings changed only by editing front matter and republishing, so an admin on a phone could see a
25
+ page's settings (on hosts that drew their own panel) but not change them.
26
+
27
+ - Two items for the owner and the hub's admins: **Share settings** (who can open it: anyone with the
28
+ link, any Freedom account, only the list; the list, add or remove an email; a password on or off,
29
+ with the link carrying the key and a Copy button; read-only lines on what a stranger and search
30
+ engines see) and **Comment settings** (who can comment: anyone, signed-in readers, nobody; who
31
+ reads them: the owner and admins, or every reader). Each opens a sheet on a phone, a panel on a
32
+ desk. A save reloads the page and reopens the sheet saying "Saved".
33
+ - `GET|POST /api/artifacts/<id>/settings` (`SETTINGS`): the reader grant naming `ownerEmail` or
34
+ one of `admins`, nobody else (401 signed out, 403 otherwise, before the page is looked up; the
35
+ publish key is not accepted). A POST must be same-origin JSON. Validated; unknown fields refused.
36
+ - `ArtifactStore.setSettings(id, patch)`, implemented by `createArtifactStore`: access, password,
37
+ comments and their visibility in place, the comments slot added to or removed from `state`, no
38
+ new version.
39
+ - **Settings changed on the page win.** Each publish records what the file said about `access:`,
40
+ `password:` (as a hash), `comments:` and `comments_visible:` (`declared`); the next publish
41
+ changes a setting only when the file's line differs from that. Access already ignored a file that
42
+ named none; this extends the idea to a line the file still carries, and to the password and
43
+ comments. `settleOnPublish` and `applySettingsPatch` are exported.
44
+ - `shareFacts` (route config): a host's own read-only rows for the Share settings sheet, replacing
45
+ the package's two.
46
+ - DETECTOR: signed in as an admin, open the page menu. No "Share settings" item means an older
47
+ version, or a store without `setSettings`. A host route file is needed at
48
+ `app/api/artifacts/[id]/settings/route.ts`; without it a save answers 404.
49
+ - REMEDY: update the dependency and add the route file (`GET` and `POST` delegating to `SETTINGS`).
50
+ A host that drew its own share-settings panel on the page can remove it and pass its facts
51
+ through `shareFacts`. Pages published before 0.31.0 behave as before until their next publish.
52
+ - Tests: `artifacts/settings.test.ts` (the page-wins rule, every setting, legacy records),
53
+ `artifacts/store.test.ts` (setSettings round trips; a republish of the same file keeps the page's
54
+ change; a changed line wins), `routes/settings.test.tsx` (visitor, reader, owner, admin; CSRF;
55
+ validation; each setting round-trips; the list and key handed to nobody else; menu items by role;
56
+ `shareFacts`), `reader/settings-sheet.test.tsx` (sheets show current values, a save posts only
57
+ what moved, the list, reopen after reload), and `check-packed` (the tarball's endpoint, items and a
58
+ password set from the menu shutting the page). Driven in headless Chrome at 390x844 and 1280x800
59
+ against the packed fixture: open the menu, Share settings, change access, save, reopen, ⋯ still
60
+ reachable.
61
+
6
62
  ## 0.30.2
7
63
 
8
64
  **Play from here is kept inside the screen on a host that insets the page.** 0.30.1 clamped the
package/README.md CHANGED
@@ -76,6 +76,7 @@ The rest, each exporting the named handler(s) from `artifacts`:
76
76
  | `app/api/artifacts/[id]/route.ts` | `GET`, `DELETE` |
77
77
  | `app/api/artifacts/[id]/assets/[name]/route.ts` | `PUT` = `PUT_ASSET` |
78
78
  | `app/api/artifacts/[id]/access/route.ts` | `GET`, `POST` = `ACCESS` |
79
+ | `app/api/artifacts/[id]/settings/route.ts` | `GET`, `POST` = `SETTINGS` (see [Share settings and Comment settings](#share-settings-and-comment-settings)) |
79
80
  | `app/api/artifacts/[id]/reads/route.ts` | `GET` = `READS` |
80
81
  | `app/api/artifacts/[id]/versions/route.ts` | `GET`, `POST` = `VERSIONS` |
81
82
  | `app/api/reader/enter/route.ts` | `GET` = `ENTER` |
@@ -130,7 +131,7 @@ cannot use is absent, never greyed out.**
130
131
  | Visitor (signed out) | Not signed in | Share, Show / Hide comments, Sign in |
131
132
  | Reader (signed in) | Signed in as Dana | Share, Show / Hide comments, Sign out |
132
133
  | Shut out (at a gated page's door) | Signed in as Dana · not on this page's list | Share, Sign out |
133
- | Owner (`ownerEmail`) | Signed in as Robin · owner of this page | Share, Show / Hide comments, Version history, Copy the markdown, Sign out |
134
+ | Owner (`ownerEmail`) | Signed in as Robin · owner of this page | Share, Show / Hide comments, Share settings, Comment settings, Version history, Copy the markdown, Sign out |
134
135
  | Admin (`admins`) | Signed in as Casey · admin of this hub | the owner's items |
135
136
 
136
137
  - **Share** opens the phone's own share sheet where there is one, and otherwise copies the link and
@@ -150,6 +151,46 @@ The server decides every item and hands the browser only what this reader may us
150
151
  source, a share link carrying a password, and the version list are never in a page rendered for
151
152
  anyone else. `PageMenu`, `roleLine` and `menuItems` are exported from `/reader`.
152
153
 
154
+ ### Share settings and Comment settings
155
+
156
+ The owner and the hub's admins change a page's settings from the menu, with no republish. Each item
157
+ opens a sheet (from the bottom on a phone, under the button on a desk):
158
+
159
+ - **Share settings.** Who can open it: anyone with the link, any Freedom account (plus the list),
160
+ or only the list. The list itself: add or remove an email. On a page open to anyone, a password
161
+ on or off (on makes a 12-character key). The link to send, with a Copy button: on a password page
162
+ it carries the key. Under them, read-only lines: what a stranger with the bare URL sees and what
163
+ search engines are told. A host that knows more (its robots.txt, who its sign-in admits) passes
164
+ `shareFacts: (artifact) => [{ label, text }]`, whose rows replace those two.
165
+ - **Comment settings.** Who can comment (anyone, signed-in readers, nobody) and who can read the
166
+ comments (the owner and admins, or every reader). A gated page takes comments only from
167
+ signed-in readers it is open to, whatever is chosen; the sheet says so.
168
+
169
+ A save that changes how the page renders reloads it and reopens the sheet saying "Saved"; adding or
170
+ removing someone on the list changes nothing on the page and does not.
171
+
172
+ The sheets need a store with `setSettings` (`createArtifactStore` has it). The gated levels and the
173
+ list need `readers`; Comment settings need `state`. Without them those parts are absent.
174
+
175
+ Both talk to `GET|POST /api/artifacts/<id>/settings` (`SETTINGS`):
176
+
177
+ - **Who:** a reader whose grant names `ownerEmail` or one of `admins`. Signed out is 401, anyone
178
+ else 403, before the page is looked up. The publish key is not accepted here.
179
+ - **CSRF:** a POST is refused (403) when its `Origin` is another site or `Sec-Fetch-Site` is not
180
+ `same-origin`, and (415) unless it is `application/json`, which a cross-site form cannot send.
181
+ - **Body**, every field optional: `{ "access": "public"|"freedom"|"invite", "password": "on"|"off",
182
+ "comments": "off"|"anyone"|"signed-in", "commentsVisible": "owner"|"readers", "add": [{ "email",
183
+ "name"? }], "remove": ["email"] }`. Unknown fields and bad values are 400 and change nothing.
184
+ - **Answer:** the settings as the sheets show them.
185
+
186
+ **Settings changed on the page win.** The record keeps what the file said about `access:`,
187
+ `password:`, `comments:` and `comments_visible:` at its last publish (`declared`). A republish
188
+ changes one of them only when the file's line differs from what it said the time before: republish
189
+ the same file and an admin's change stands; edit the line and the file wins. Dropping a line counts
190
+ as a change for the password and comments (as before, it takes the password off and comments back to
191
+ off), and never for `access:`, which a file that names none leaves alone. A page last published
192
+ before 0.31.0 has no `declared` and takes its file on its next publish, as every publish did.
193
+
153
194
  ## Version history
154
195
 
155
196
  Every page names its version under the summary, "Version 4 · Updated <minute>", and the page
@@ -240,7 +281,9 @@ on a defined word opens its definition instead of seeking.
240
281
  `password: <string>` shuts the body behind a door. The title, summary and unfurl stay, so the
241
282
  link still reads as itself in a thread. It opens on `?key=<password>` in the URL, or on the
242
283
  cookie that first open sets, which holds a hash bound to the page id: it names no password and
243
- opens no other page. Republishing without the line takes the door down.
284
+ opens no other page. Republishing without the line takes the door down. The owner and admins can
285
+ also turn it on or off from the page menu (Share settings); a republish of the same file leaves
286
+ their change alone.
244
287
 
245
288
  ## A confidential page (`access:`)
246
289
 
@@ -20,3 +20,5 @@ export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS,
20
20
  export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER, type PersonalStore, type PersonalNote, type PersonalNoteValue } from './personal-store.js';
21
21
  export { deepgramTranscriber, openaiTranscriber, type Transcriber } from './transcribe.js';
22
22
  export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
23
+ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
24
+ export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
@@ -20,3 +20,4 @@ export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS,
20
20
  export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER } from './personal-store.js';
21
21
  export { deepgramTranscriber, openaiTranscriber } from './transcribe.js';
22
22
  export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
23
+ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey } from './settings.js';
@@ -0,0 +1,62 @@
1
+ import type { Access } from './reader.js';
2
+ import type { StateConfig } from './state.js';
3
+ import { type CommentsMode, type CommentsVisible } from './comments.js';
4
+ /** What the FILE said at its last publish, one entry per setting; null means it said nothing.
5
+ * The password is kept as a hash, never a second copy of the key. */
6
+ export type DeclaredSettings = {
7
+ access: Access | 'public' | null;
8
+ password: string | null;
9
+ comments: CommentsMode | null;
10
+ commentsVisible: CommentsVisible | null;
11
+ };
12
+ /** The settings as they apply to the page; undefined is absent (public, no password, comments off, owner). */
13
+ export type LiveSettings = {
14
+ access?: Access;
15
+ password?: string;
16
+ comments?: CommentsMode;
17
+ commentsVisible?: CommentsVisible;
18
+ state?: StateConfig;
19
+ };
20
+ export declare const passwordDigest: (p: string | undefined) => string | null;
21
+ /** What a file declares, as the record keeps it. */
22
+ export declare function declaredOf(meta: {
23
+ access?: Access | 'public';
24
+ password?: string;
25
+ comments?: CommentsMode;
26
+ commentsVisible?: CommentsVisible;
27
+ }): DeclaredSettings;
28
+ /** The page's state with the comments slot set for `mode` and `visible`: removed when off, put
29
+ * back (or changed) when on. A state left with no slots at all is no state. Null when the page
30
+ * declares a slot of its own named `comments`, which comments cannot share. */
31
+ export declare function withComments(state: StateConfig | undefined, mode: CommentsMode | undefined, visible: CommentsVisible | undefined, ownSlot?: boolean): StateConfig | undefined | null;
32
+ /** The settings a publish leaves on the page. `existing` is the record before it (null on the
33
+ * first publish); a record with no `declared` predates 0.31.0 and takes the file as it always did. */
34
+ export declare function settleOnPublish(existing: (LiveSettings & {
35
+ declared?: DeclaredSettings;
36
+ }) | null, meta: {
37
+ access?: Access | 'public';
38
+ password?: string;
39
+ comments?: CommentsMode;
40
+ commentsVisible?: CommentsVisible;
41
+ state?: StateConfig;
42
+ }): LiveSettings & {
43
+ declared: DeclaredSettings;
44
+ };
45
+ /** What an owner or admin may change from the page. Absent leaves a setting as it is. */
46
+ export type SettingsPatch = {
47
+ access?: Access | 'public';
48
+ /** A key to set, or null to take the password off. */
49
+ password?: string | null;
50
+ comments?: CommentsMode;
51
+ commentsVisible?: CommentsVisible;
52
+ };
53
+ /** The live settings after a patch, or an error a person can read. */
54
+ export declare function applySettingsPatch(existing: LiveSettings, patch: SettingsPatch): {
55
+ ok: true;
56
+ live: LiveSettings;
57
+ } | {
58
+ ok: false;
59
+ error: string;
60
+ };
61
+ /** A fresh key for a password turned on from the page. 12 characters, about 59 bits. */
62
+ export declare function newPageKey(): string;
@@ -0,0 +1,86 @@
1
+ // A page's share and comment settings, changed two ways: by its file on publish, and by its owner
2
+ // or an admin from the page menu (0.31.0). Settings changed on the page WIN. A publish changes one
3
+ // only when the file names a value that differs from what the file named the time before, which
4
+ // the record keeps as `declared`. That is how `access:` already behaved for a line the file drops
5
+ // (absent leaves it alone); this extends the same idea to a line the file still carries, and to
6
+ // the password and the comment settings, so an admin's change is never quietly undone by the
7
+ // next republish of an unchanged file.
8
+ //
9
+ // Pure and server-only (node:crypto). The Firestore store applies what this decides.
10
+ import { createHash, randomInt } from 'node:crypto';
11
+ import { COMMENTS_SLOT, mergeCommentsState } from './comments.js';
12
+ export const passwordDigest = (p) => (p ? createHash('sha256').update(`declared\n${p}`).digest('hex') : null);
13
+ /** What a file declares, as the record keeps it. */
14
+ export function declaredOf(meta) {
15
+ return { access: meta.access ?? null, password: passwordDigest(meta.password), comments: meta.comments ?? null, commentsVisible: meta.commentsVisible ?? null };
16
+ }
17
+ /** The page's state with the comments slot set for `mode` and `visible`: removed when off, put
18
+ * back (or changed) when on. A state left with no slots at all is no state. Null when the page
19
+ * declares a slot of its own named `comments`, which comments cannot share. */
20
+ export function withComments(state, mode, visible, ownSlot = false) {
21
+ let base = state;
22
+ if (base && !ownSlot && Object.hasOwn(base.slots, COMMENTS_SLOT)) {
23
+ const { [COMMENTS_SLOT]: _gone, ...rest } = base.slots;
24
+ base = Object.keys(rest).length ? { ...base, slots: rest } : undefined;
25
+ }
26
+ const merged = mergeCommentsState(base, mode, visible);
27
+ return merged.ok ? merged.state : null;
28
+ }
29
+ /** The settings a publish leaves on the page. `existing` is the record before it (null on the
30
+ * first publish); a record with no `declared` predates 0.31.0 and takes the file as it always did. */
31
+ export function settleOnPublish(existing, meta) {
32
+ const declared = declaredOf(meta);
33
+ const fileAccess = meta.access === 'public' ? undefined : meta.access;
34
+ if (!existing) {
35
+ return { access: fileAccess, password: meta.password, comments: meta.comments, commentsVisible: meta.commentsVisible, state: meta.state, declared };
36
+ }
37
+ const d = existing.declared;
38
+ // The file's value stands only where it changed since the last publish; else the page's stands.
39
+ const kept = (k) => !!d && d[k] === declared[k];
40
+ // Access: a file that names none never touches it (sticky since 0.13), with or without `declared`.
41
+ const access = meta.access === undefined || kept('access') ? existing.access : fileAccess;
42
+ const password = kept('password') ? existing.password : meta.password;
43
+ const comments = kept('comments') ? existing.comments : meta.comments;
44
+ const commentsVisible = kept('commentsVisible') ? existing.commentsVisible : meta.commentsVisible;
45
+ if (comments === meta.comments && commentsVisible === meta.commentsVisible) {
46
+ return { access, password, comments, commentsVisible, state: meta.state, declared };
47
+ }
48
+ // The page's comment settings outlive the file's: rebuild the slot from them. The file's
49
+ // comments slot (when `comments:` put one there) is replaced; a slot the page itself declares
50
+ // under that name is never touched, and then comments stay as the file has them.
51
+ const fileMadeSlot = !!meta.comments && meta.comments !== 'off';
52
+ const state = withComments(meta.state, comments, commentsVisible, !fileMadeSlot && !!meta.state && Object.hasOwn(meta.state.slots, COMMENTS_SLOT));
53
+ if (state === null)
54
+ return { access, password, comments: meta.comments, commentsVisible: meta.commentsVisible, state: meta.state, declared };
55
+ return { access, password, comments, commentsVisible, state, declared };
56
+ }
57
+ /** The live settings after a patch, or an error a person can read. */
58
+ export function applySettingsPatch(existing, patch) {
59
+ const live = { ...existing };
60
+ if (patch.access !== undefined)
61
+ live.access = patch.access === 'public' ? undefined : patch.access;
62
+ if (patch.password !== undefined)
63
+ live.password = patch.password ?? undefined;
64
+ if (patch.comments !== undefined || patch.commentsVisible !== undefined) {
65
+ const mode = patch.comments ?? existing.comments;
66
+ const visible = patch.commentsVisible ?? existing.commentsVisible;
67
+ const on = !!existing.comments && existing.comments !== 'off';
68
+ const ownSlot = !on && !!existing.state && Object.hasOwn(existing.state.slots, COMMENTS_SLOT);
69
+ const state = withComments(existing.state, mode, visible, ownSlot);
70
+ if (state === null)
71
+ return { ok: false, error: `this page declares its own slot named "${COMMENTS_SLOT}", so shared comments cannot be turned on from the page` };
72
+ live.comments = mode === 'off' ? undefined : mode;
73
+ live.commentsVisible = visible;
74
+ live.state = state;
75
+ }
76
+ return { ok: true, live };
77
+ }
78
+ // No 0/o, 1/l/i, like page ids: a key read aloud or retyped from a screenshot must survive it.
79
+ const KEY_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
80
+ /** A fresh key for a password turned on from the page. 12 characters, about 59 bits. */
81
+ export function newPageKey() {
82
+ let s = '';
83
+ for (let i = 0; i < 12; i++)
84
+ s += KEY_ALPHABET[randomInt(KEY_ALPHABET.length)];
85
+ return s;
86
+ }
@@ -5,6 +5,7 @@ import type { StateConfig } from './state.js';
5
5
  import type { ThemeMode } from '../brand/theme.js';
6
6
  import type { Definition } from './definitions.js';
7
7
  import type { CommentsMode, CommentsVisible } from './comments.js';
8
+ import { type DeclaredSettings, type SettingsPatch } from './settings.js';
8
9
  export type ArtifactRecord = {
9
10
  id: string;
10
11
  title: string;
@@ -30,6 +31,10 @@ export type ArtifactRecord = {
30
31
  /** Who may leave shared comments; absent is off. Personal notes need nothing. */
31
32
  comments?: CommentsMode;
32
33
  commentsVisible?: CommentsVisible;
34
+ /** What the file said about access, password and comments at its last publish (0.31.0), so a
35
+ * republish changes a setting only when the file's line changed: settings changed on the page
36
+ * win (artifacts/settings.ts). Absent on pages last published before 0.31.0. */
37
+ declared?: DeclaredSettings;
33
38
  markdown: string;
34
39
  createdAt: string;
35
40
  updatedAt: string;
@@ -89,6 +94,9 @@ export interface ArtifactStore {
89
94
  bumpViews(id: string): Promise<void>;
90
95
  /** Set who may read a page without republishing it; 'public' opens it. False if no such page. */
91
96
  setAccess?(id: string, access: Access | 'public'): Promise<boolean>;
97
+ /** Change a page's share and comment settings from the page, without a new version. Null when
98
+ * no such page; throws an Error whose message a person can read when the change cannot apply. */
99
+ setSettings?(id: string, patch: SettingsPatch): Promise<ArtifactRecord | null>;
92
100
  /** Every version, newest first, current included, without bodies. Null when no such page. */
93
101
  history?(id: string): Promise<VersionEntry[] | null>;
94
102
  /** One version by number, current included. Null when the page or the version does not exist. */
@@ -9,6 +9,7 @@
9
9
  // first save of a page still carrying the array moves it into the subcollection.
10
10
  import { randomInt } from 'node:crypto';
11
11
  import { FieldValue } from 'firebase-admin/firestore';
12
+ import { applySettingsPatch, settleOnPublish } from './settings.js';
12
13
  /** A change note is one line: whitespace collapsed, capped, empty means none. */
13
14
  export const MAX_CHANGE_NOTE_CHARS = 280;
14
15
  export function cleanNote(note) {
@@ -41,6 +42,20 @@ export function createArtifactStore(db, collection = 'artifacts') {
41
42
  await ref.update({ access: access === 'public' ? FieldValue.delete() : access });
42
43
  return true;
43
44
  },
45
+ setSettings: async (id, patch) => {
46
+ const a = await getArtifact(col, id);
47
+ if (!a)
48
+ return null;
49
+ const r = applySettingsPatch(a, patch);
50
+ if (!r.ok)
51
+ throw new Error(r.error);
52
+ const v = r.live;
53
+ const or = (x) => (x === undefined ? FieldValue.delete() : x);
54
+ await col().doc(id).update({
55
+ access: or(v.access), password: or(v.password), comments: or(v.comments), commentsVisible: or(v.commentsVisible), state: or(v.state),
56
+ });
57
+ return getArtifact(col, id);
58
+ },
44
59
  history: (id) => historyOf(col, id),
45
60
  version: (id, n) => versionOf(col, id, n),
46
61
  setNote: (id, n, note) => setNote(col, id, n, note),
@@ -130,15 +145,10 @@ async function saveArtifact(col, input) {
130
145
  ...(input.meta.narration ? { narration: input.meta.narration } : {}),
131
146
  ...(input.meta.timings ? { timings: input.meta.timings } : {}),
132
147
  ...(input.meta.narrationHash ? { narrationHash: input.meta.narrationHash } : {}),
133
- ...(input.meta.password ? { password: input.meta.password } : {}),
134
- ...(input.meta.access && input.meta.access !== 'public' ? { access: input.meta.access } : {}),
135
- ...(input.meta.state ? { state: input.meta.state } : {}),
136
148
  ...(input.meta.theme ? { theme: input.meta.theme } : {}),
137
149
  ...(input.meta.pack ? { pack: input.meta.pack } : {}),
138
150
  ...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
139
151
  ...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
140
- ...(input.meta.comments ? { comments: input.meta.comments } : {}),
141
- ...(input.meta.commentsVisible ? { commentsVisible: input.meta.commentsVisible } : {}),
142
152
  };
143
153
  if (input.id) {
144
154
  const existing = await getArtifact(col, input.id);
@@ -167,28 +177,26 @@ async function saveArtifact(col, input) {
167
177
  });
168
178
  }
169
179
  const next = fresh ? current + 1 : current;
170
- // An update merges, so a password the file no longer carries has to be cleared on purpose:
171
- // otherwise removing the line from the file would leave the door up on the live page.
172
- const password = input.meta.password ? {} : { password: FieldValue.delete() };
180
+ // Access, the password and the comment settings: the file's line stands only where it changed
181
+ // since the last publish, so a setting changed on the page is never undone by a republish of
182
+ // an unchanged file (settings.ts). An update merges, so anything that settles to absent is
183
+ // cleared on purpose: removing the password line takes the door down, `access: public` opens.
184
+ const live = settleOnPublish(existing, input.meta);
185
+ const or = (x) => (x === undefined ? FieldValue.delete() : x);
186
+ const settings = {
187
+ access: or(live.access), password: or(live.password), state: or(live.state),
188
+ comments: or(live.comments), commentsVisible: or(live.commentsVisible), declared: live.declared,
189
+ };
173
190
  // A subtitle the file no longer carries is gone from the page, like any other content.
174
191
  const subtitle = input.meta.subtitle ? {} : { subtitle: FieldValue.delete() };
175
192
  // Who it is for is a label on the content, never an access control: absent clears it.
176
193
  const to = input.meta.to ? {} : { to: FieldValue.delete() };
177
- // Access is the opposite of password on purpose: absent leaves it alone, and only an explicit
178
- // `access: public` opens the page. Reopening a confidential page must never be a side effect.
179
- const access = input.meta.access === 'public' ? { access: FieldValue.delete() } : {};
180
- // Content, like the body: a republish that no longer declares state removes the slots.
181
- const state = input.meta.state ? {} : { state: FieldValue.delete() };
182
194
  // How the page looks is content too: a line the file no longer carries goes back to the default.
183
195
  const look = {
184
196
  ...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
185
197
  ...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
186
198
  ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
187
199
  ...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
188
- // Comments are not an access control, so a line the file no longer carries goes back to
189
- // off / owner. The comments already left are kept, like every answer.
190
- ...(input.meta.comments ? {} : { comments: FieldValue.delete() }),
191
- ...(input.meta.commentsVisible ? {} : { commentsVisible: FieldValue.delete() }),
192
200
  };
193
201
  // `voice: none` is the page declaring it has no audio. An update merges, so the recording it
194
202
  // had would otherwise survive every republish; clear it on purpose, and the player goes with it.
@@ -196,7 +204,7 @@ async function saveArtifact(col, input) {
196
204
  ? { voice: FieldValue.delete(), narration: FieldValue.delete(), timings: FieldValue.delete(), narrationHash: FieldValue.delete() }
197
205
  : {};
198
206
  await ref.update({
199
- ...fields, ...password, ...subtitle, ...to, ...access, ...state, ...look, ...audio, markdown: input.markdown, version: next,
207
+ ...fields, ...settings, ...subtitle, ...to, ...look, ...audio, markdown: input.markdown, version: next,
200
208
  // `updatedAt` is when this version went live; finishing its own publish does not move it.
201
209
  ...(fresh || input.markdown !== existing.markdown ? { updatedAt: now } : {}),
202
210
  note: nextNote ?? FieldValue.delete(),
@@ -205,9 +213,16 @@ async function saveArtifact(col, input) {
205
213
  return { id: input.id, version: next, created: false };
206
214
  }
207
215
  const id = newArtifactId();
216
+ const live = settleOnPublish(null, input.meta);
208
217
  const rec = {
209
218
  id,
210
219
  ...fields,
220
+ ...(live.access ? { access: live.access } : {}),
221
+ ...(live.password ? { password: live.password } : {}),
222
+ ...(live.state ? { state: live.state } : {}),
223
+ ...(live.comments ? { comments: live.comments } : {}),
224
+ ...(live.commentsVisible ? { commentsVisible: live.commentsVisible } : {}),
225
+ declared: live.declared,
211
226
  markdown: input.markdown,
212
227
  createdAt: now,
213
228
  updatedAt: now,
@@ -173,10 +173,23 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
173
173
  const el = offerRef.current;
174
174
  if (!offer || !el || offer.nudged)
175
175
  return;
176
- const vw = document.documentElement.clientWidth || window.innerWidth;
176
+ // Inside the screen AND inside whatever box clips the bar (a host's rounded page card).
177
+ let lo = 0;
178
+ let hi = document.documentElement.clientWidth || window.innerWidth;
179
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
180
+ const o = getComputedStyle(p);
181
+ if (o.overflowX !== 'visible' || o.overflow !== 'visible') {
182
+ const b = p.getBoundingClientRect();
183
+ if (b.width > 0) {
184
+ lo = Math.max(lo, b.left);
185
+ hi = Math.min(hi, b.right);
186
+ }
187
+ }
188
+ }
189
+ const vw = hi;
177
190
  const r = el.getBoundingClientRect();
178
191
  // No layout to measure (a server render, a test DOM): leave the placement as computed.
179
- const shift = r.width === 0 ? 0 : r.right > vw - 8 ? vw - 8 - r.right : r.left < 8 ? 8 - r.left : 0;
192
+ const shift = r.width === 0 ? 0 : r.right > vw - 8 ? vw - 8 - r.right : r.left < lo + 8 ? lo + 8 - r.left : 0;
180
193
  // Once per tap: measured again, an element with no layout would move forever.
181
194
  setOffer({ ...offer, left: offer.left + (Math.abs(shift) >= 1 ? shift : 0), nudged: true });
182
195
  }, [offer]);
@@ -2,6 +2,7 @@ export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming
2
2
  export { PlayerBar } from './player-bar.js';
3
3
  export { CommentLayer } from './comment-layer.js';
4
4
  export { PageMenu, roleLine, menuItems, type PageMenuProps, type PageRole } from './page-menu.js';
5
+ export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY, type PageSettings, type SettingsFact, type SheetKind, type AccessLevel } from './settings-sheet.js';
5
6
  export { VersionHistory, HistoryPanel, VersionList, type HistoryItem } from './version-history.js';
6
7
  export { requestPins, announcePins, signInBack, PINS_SET, PINS_STATE } from './page-bridge.js';
7
8
  export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
@@ -4,6 +4,7 @@ export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifac
4
4
  export { PlayerBar } from './player-bar.js';
5
5
  export { CommentLayer } from './comment-layer.js';
6
6
  export { PageMenu, roleLine, menuItems } from './page-menu.js';
7
+ export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY } from './settings-sheet.js';
7
8
  export { VersionHistory, HistoryPanel, VersionList } from './version-history.js';
8
9
  export { requestPins, announcePins, signInBack, PINS_SET, PINS_STATE } from './page-bridge.js';
9
10
  export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
@@ -1,4 +1,5 @@
1
1
  import { type HistoryItem } from './version-history.js';
2
+ import { type PageSettings } from './settings-sheet.js';
2
3
  export type PageRole = 'visitor' | 'reader' | 'owner' | 'admin';
3
4
  export type PageMenuProps = {
4
5
  role: PageRole;
@@ -26,9 +27,12 @@ export type PageMenuProps = {
26
27
  } | null;
27
28
  /** The published source, for the owner and admins only. */
28
29
  markdown: string | null;
30
+ /** The page's share and comment settings, for the owner and admins only; null for everyone
31
+ * else, so nobody else's page carries the list or the key. */
32
+ settings?: PageSettings | null;
29
33
  };
30
34
  /** The menu's first line: who this reader is to this page. */
31
35
  export declare function roleLine(role: PageRole, name: string | null, shutOut?: boolean): string;
32
36
  /** The items this reader gets, in order. Exported so the server markup and tests can name them. */
33
- export declare function menuItems(p: Pick<PageMenuProps, 'share' | 'comments' | 'signIn' | 'signOut' | 'history' | 'markdown'>): string[];
37
+ export declare function menuItems(p: Pick<PageMenuProps, 'share' | 'comments' | 'signIn' | 'signOut' | 'history' | 'markdown' | 'settings'>): string[];
34
38
  export declare function PageMenu(props: PageMenuProps): import("react").JSX.Element;
@@ -6,7 +6,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  // that role. Items a role cannot use are absent, never greyed out.
7
7
  //
8
8
  // Release 1 (0.30.0): Share, Show / Hide comments, Sign in / Sign out, Version history and Copy
9
- // the markdown. The server decides every item (routes/artifacts.tsx) and passes only what this
9
+ // the markdown. Release 2 (0.31.0): Share settings and Comment settings for the owner and the
10
+ // hub's admins, each opening a sheet (settings-sheet.tsx) that changes the page without a republish. The server decides every item (routes/artifacts.tsx) and passes only what this
10
11
  // reader may have: the markdown source and the history list never reach a reader who may not use
11
12
  // them, so nothing here has to hide anything.
12
13
  //
@@ -20,6 +21,7 @@ import { createPortal } from 'react-dom';
20
21
  import { themeVarsAround } from './theme-vars.js';
21
22
  import { HistoryPanel, VersionList } from './version-history.js';
22
23
  import { PINS_STATE, requestPins, signInBack } from './page-bridge.js';
24
+ import { REOPEN_KEY, SettingsSheet } from './settings-sheet.js';
23
25
  /** The menu's first line: who this reader is to this page. */
24
26
  export function roleLine(role, name, shutOut = false) {
25
27
  if (role === 'visitor' || !name)
@@ -35,6 +37,8 @@ export function menuItems(p) {
35
37
  return [
36
38
  p.share ? 'share' : null,
37
39
  p.comments ? 'comments' : null,
40
+ p.settings ? 'share-settings' : null,
41
+ p.settings && p.settings.comments !== null ? 'comment-settings' : null,
38
42
  p.history ? 'history' : null,
39
43
  p.markdown !== null ? 'markdown' : null,
40
44
  p.signOut ? 'sign-out' : p.signIn ? 'sign-in' : null,
@@ -75,7 +79,7 @@ async function copyText(text) {
75
79
  }
76
80
  }
77
81
  export function PageMenu(props) {
78
- const { role, name, shutOut, share, comments, signIn, signOut, history, markdown } = props;
82
+ const { role, name, shutOut, share, comments, signIn, signOut, history, markdown, settings } = props;
79
83
  const items = menuItems(props);
80
84
  const [mounted, setMounted] = useState(false);
81
85
  const [open, setOpen] = useState(false);
@@ -84,6 +88,7 @@ export function PageMenu(props) {
84
88
  const [pins, setPins] = useState(true);
85
89
  const [said, setSaid] = useState(null);
86
90
  const [historyOpen, setHistoryOpen] = useState(false);
91
+ const [sheet, setSheet] = useState(null);
87
92
  const marker = useRef(null);
88
93
  const button = useRef(null);
89
94
  const menu = useRef(null);
@@ -92,6 +97,18 @@ export function PageMenu(props) {
92
97
  setMounted(true);
93
98
  const onPins = (e) => setPins(e.detail === true);
94
99
  window.addEventListener(PINS_STATE, onPins);
100
+ // A settings save reloads the page; open the sheet it came from again, saying it saved.
101
+ if (settings) {
102
+ try {
103
+ const kind = window.sessionStorage.getItem(REOPEN_KEY);
104
+ window.sessionStorage.removeItem(REOPEN_KEY);
105
+ if (kind === 'share' || kind === 'comments') {
106
+ setPhone(isPhone());
107
+ setSheet({ kind, saved: true });
108
+ }
109
+ }
110
+ catch { /* no storage: the page itself shows what changed */ }
111
+ }
95
112
  return () => window.removeEventListener(PINS_STATE, onPins);
96
113
  }, []);
97
114
  const close = useCallback((focus = true) => {
@@ -150,6 +167,10 @@ export function PageMenu(props) {
150
167
  setPins(!pins);
151
168
  close();
152
169
  };
170
+ const onSheet = (kind) => {
171
+ setOpen(false);
172
+ setSheet({ kind, saved: false });
173
+ };
153
174
  const onHistory = () => {
154
175
  setOpen(false);
155
176
  setHistoryOpen(true);
@@ -176,6 +197,6 @@ export function PageMenu(props) {
176
197
  color: 'var(--a-ink, #111)', background: 'color-mix(in srgb, var(--a-ground, #fff) 82%, transparent)',
177
198
  border: '1px solid var(--a-line, rgba(127,127,127,0.3))', boxShadow: '0 1px 4px rgba(0,0,0,0.12)',
178
199
  backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
179
- }, children: _jsxs("svg", { width: "18", height: "18", viewBox: "0 0 18 18", "aria-hidden": true, focusable: "false", children: [_jsx("circle", { cx: "3.5", cy: "9", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "9", cy: "9", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "14.5", cy: "9", r: "1.6", fill: "currentColor" })] }) }), open && phone ? _jsx("div", { "aria-hidden": true, "data-page-menu-scrim": true, style: { position: 'fixed', inset: 0, zIndex: 59, background: 'rgba(0,0,0,0.28)' } }) : null, open ? (_jsxs("div", { ref: menu, "data-page-menu": true, role: "menu", "aria-label": "Page menu", style: panelStyle, children: [_jsx("p", { "data-page-menu-role": true, style: { margin: 0, padding: phone ? '10px 12px 8px' : '8px 10px 6px', fontSize: 12, lineHeight: 1.35, opacity: 0.7, overflowWrap: 'anywhere' }, children: line }), items.length ? _jsx("div", { "aria-hidden": true, style: { height: 1, margin: '0 6px 4px', background: 'var(--a-line, rgba(127,127,127,0.3))' } }) : null, share ? (_jsxs("button", { type: "button", role: "menuitem", "data-page-menu-item": "share", onClick: () => void onShare(), style: itemStyle, children: [said === 'Link copied' || said === 'Could not copy the link' ? said : 'Share', share.note ? _jsx("span", { "data-page-menu-note": true, style: sub, children: share.note }) : null] })) : null, comments ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "comments", onClick: onPins, style: itemStyle, children: pins ? 'Hide comments' : 'Show comments' })) : null, history ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "history", "aria-haspopup": "dialog", onClick: onHistory, style: itemStyle, children: "Version history" })) : null, markdown !== null ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "markdown", onClick: () => void onMarkdown(), style: itemStyle, children: said === 'Markdown copied' || said === 'Could not copy the markdown' ? said : 'Copy the markdown' })) : null, signOut ? (_jsx("a", { role: "menuitem", "data-page-menu-item": "sign-out", href: signOut, style: itemStyle, children: "Sign out" })) : signIn ? (_jsx("a", { role: "menuitem", "data-page-menu-item": "sign-in", href: signIn, onClick: (e) => { e.currentTarget.href = signInBack(signIn); }, style: itemStyle, children: "Sign in" })) : null, _jsx("span", { role: "status", "aria-live": "polite", style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, children: said ?? '' })] })) : null, historyOpen && history ? (_jsx(HistoryPanel, { items: history.items, base: history.base, viewing: history.viewing, anchor: button, onClose: () => { setHistoryOpen(false); button.current?.focus(); }, onOutside: () => setHistoryOpen(false) })) : null] })) : null;
200
+ }, children: _jsxs("svg", { width: "18", height: "18", viewBox: "0 0 18 18", "aria-hidden": true, focusable: "false", children: [_jsx("circle", { cx: "3.5", cy: "9", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "9", cy: "9", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "14.5", cy: "9", r: "1.6", fill: "currentColor" })] }) }), open && phone ? _jsx("div", { "aria-hidden": true, "data-page-menu-scrim": true, style: { position: 'fixed', inset: 0, zIndex: 59, background: 'rgba(0,0,0,0.28)' } }) : null, open ? (_jsxs("div", { ref: menu, "data-page-menu": true, role: "menu", "aria-label": "Page menu", style: panelStyle, children: [_jsx("p", { "data-page-menu-role": true, style: { margin: 0, padding: phone ? '10px 12px 8px' : '8px 10px 6px', fontSize: 12, lineHeight: 1.35, opacity: 0.7, overflowWrap: 'anywhere' }, children: line }), items.length ? _jsx("div", { "aria-hidden": true, style: { height: 1, margin: '0 6px 4px', background: 'var(--a-line, rgba(127,127,127,0.3))' } }) : null, share ? (_jsxs("button", { type: "button", role: "menuitem", "data-page-menu-item": "share", onClick: () => void onShare(), style: itemStyle, children: [said === 'Link copied' || said === 'Could not copy the link' ? said : 'Share', share.note ? _jsx("span", { "data-page-menu-note": true, style: sub, children: share.note }) : null] })) : null, comments ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "comments", onClick: onPins, style: itemStyle, children: pins ? 'Hide comments' : 'Show comments' })) : null, settings ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "share-settings", "aria-haspopup": "dialog", onClick: () => onSheet('share'), style: itemStyle, children: "Share settings" })) : null, settings && settings.comments !== null ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "comment-settings", "aria-haspopup": "dialog", onClick: () => onSheet('comments'), style: itemStyle, children: "Comment settings" })) : null, history ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "history", "aria-haspopup": "dialog", onClick: onHistory, style: itemStyle, children: "Version history" })) : null, markdown !== null ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "markdown", onClick: () => void onMarkdown(), style: itemStyle, children: said === 'Markdown copied' || said === 'Could not copy the markdown' ? said : 'Copy the markdown' })) : null, signOut ? (_jsx("a", { role: "menuitem", "data-page-menu-item": "sign-out", href: signOut, style: itemStyle, children: "Sign out" })) : signIn ? (_jsx("a", { role: "menuitem", "data-page-menu-item": "sign-in", href: signIn, onClick: (e) => { e.currentTarget.href = signInBack(signIn); }, style: itemStyle, children: "Sign in" })) : null, _jsx("span", { role: "status", "aria-live": "polite", style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, children: said ?? '' })] })) : null, sheet && settings ? (_jsx(SettingsSheet, { kind: sheet.kind, settings: settings, saved: sheet.saved, phone: phone, vars: vars, onCopy: copyText, onClose: () => { setSheet(null); button.current?.focus(); } })) : null, historyOpen && history ? (_jsx(HistoryPanel, { items: history.items, base: history.base, viewing: history.viewing, anchor: button, onClose: () => { setHistoryOpen(false); button.current?.focus(); }, onOutside: () => setHistoryOpen(false) })) : null] })) : null;
180
201
  return (_jsxs(_Fragment, { children: [_jsx("span", { ref: marker, hidden: true, "data-page-menu-for": role, "data-page-menu-items": items.join(' '), children: history ? _jsx(VersionList, { items: history.items, base: history.base, marker: true }) : null }), ui ? createPortal(ui, document.body) : null] }));
181
202
  }
@@ -0,0 +1,55 @@
1
+ export type AccessLevel = 'public' | 'freedom' | 'invite';
2
+ export type SettingsFact = {
3
+ label: string;
4
+ text: string;
5
+ };
6
+ /** Everything the sheets show and change, for one page. The endpoint answers in the same shape. */
7
+ export type PageSettings = {
8
+ /** POST here to change, GET to read. */
9
+ endpoint: string;
10
+ access: AccessLevel;
11
+ /** The levels this host can offer: 'public' always; the gated ones only on a host that keeps a list. */
12
+ levels: AccessLevel[];
13
+ /** A password shuts the page (only meaningful while it is open to anyone with the link). */
14
+ password: boolean;
15
+ /** The link to send: with the key when a password is on, else the page. */
16
+ link: string;
17
+ /** Who is on the page's list, or null on a host that keeps none. */
18
+ list: {
19
+ email: string;
20
+ name?: string;
21
+ }[] | null;
22
+ /** Who may comment, or null on a host that keeps no comments. */
23
+ comments: 'off' | 'anyone' | 'signed-in' | null;
24
+ commentsVisible: 'owner' | 'readers';
25
+ /** A gated page takes comments only from signed-in readers, whatever the setting says. */
26
+ gated: boolean;
27
+ /** Read-only lines: what a stranger sees, search engines, anything the host adds. */
28
+ facts: SettingsFact[];
29
+ };
30
+ export type SheetKind = 'share' | 'comments';
31
+ export declare const ACCESS_LABEL: Record<AccessLevel, string>;
32
+ export declare const COMMENTS_LABEL: {
33
+ readonly anyone: "Anyone";
34
+ readonly 'signed-in': "Signed-in readers";
35
+ readonly off: "Nobody";
36
+ };
37
+ export declare const VISIBLE_LABEL: {
38
+ readonly owner: "Owner and admins";
39
+ readonly readers: "Every reader";
40
+ };
41
+ /** Where a reload leaves a note for the sheet to open again with. */
42
+ export declare const REOPEN_KEY = "artifact:settings-reopen";
43
+ /** The body of a settings POST, from what the sheet holds against what the page had. Only what
44
+ * changed is sent, so a save never touches a setting nobody moved. */
45
+ export declare function settingsChanges(was: PageSettings, now: Pick<PageSettings, 'access' | 'password' | 'comments' | 'commentsVisible'>): Record<string, unknown>;
46
+ export declare function SettingsSheet({ kind, settings, phone, vars, onClose, onCopy, saved }: {
47
+ kind: SheetKind;
48
+ settings: PageSettings;
49
+ phone: boolean;
50
+ vars: Record<string, string>;
51
+ onClose: () => void;
52
+ onCopy: (text: string) => Promise<boolean>;
53
+ /** Opened again after the reload a save caused: say so. */
54
+ saved?: boolean;
55
+ }): import("react").JSX.Element;
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The page menu's two owner-and-admin sheets (0.31.0): Share settings and Comment settings. The
4
+ // server hands the page these settings ONLY for the owner and the hub's admins (routes/artifacts
5
+ // `settingsFor`), and every change goes through POST /api/artifacts/<id>/settings, which checks
6
+ // the same thing again. On a phone a sheet from the bottom, on a desk a panel under the button,
7
+ // styled like the menu itself.
8
+ //
9
+ // A save that changes how the page renders (who opens it, the password, the comments) reloads
10
+ // the page so it shows the new settings, and the sheet opens again on the other side saying
11
+ // "Saved". Adding or removing someone on the list changes nothing on the page, so it does not.
12
+ import { useEffect, useRef, useState } from 'react';
13
+ import { NO_ZOOM_FONT } from './no-zoom.js';
14
+ export const ACCESS_LABEL = {
15
+ public: 'Anyone with the link',
16
+ freedom: 'Any Freedom account',
17
+ invite: 'Only the list',
18
+ };
19
+ export const COMMENTS_LABEL = { anyone: 'Anyone', 'signed-in': 'Signed-in readers', off: 'Nobody' };
20
+ export const VISIBLE_LABEL = { owner: 'Owner and admins', readers: 'Every reader' };
21
+ /** Where a reload leaves a note for the sheet to open again with. */
22
+ export const REOPEN_KEY = 'artifact:settings-reopen';
23
+ /** The body of a settings POST, from what the sheet holds against what the page had. Only what
24
+ * changed is sent, so a save never touches a setting nobody moved. */
25
+ export function settingsChanges(was, now) {
26
+ const body = {};
27
+ if (now.access !== was.access)
28
+ body.access = now.access;
29
+ if (now.password !== was.password)
30
+ body.password = now.password ? 'on' : 'off';
31
+ if (now.comments !== null && now.comments !== was.comments)
32
+ body.comments = now.comments;
33
+ if (now.commentsVisible !== was.commentsVisible)
34
+ body.commentsVisible = now.commentsVisible;
35
+ return body;
36
+ }
37
+ async function send(endpoint, body) {
38
+ try {
39
+ const r = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body) });
40
+ const j = (await r.json().catch(() => null));
41
+ if (!r.ok || !j)
42
+ return { ok: false, error: j?.error ?? `Could not save (${r.status})` };
43
+ return { ok: true, settings: j };
44
+ }
45
+ catch {
46
+ return { ok: false, error: 'Could not reach the page. Check the connection and try again.' };
47
+ }
48
+ }
49
+ export function SettingsSheet({ kind, settings, phone, vars, onClose, onCopy, saved = false }) {
50
+ const [s, setS] = useState(settings);
51
+ const [access, setAccess] = useState(settings.access);
52
+ const [password, setPassword] = useState(settings.password);
53
+ const [comments, setComments] = useState(settings.comments);
54
+ const [visible, setVisible] = useState(settings.commentsVisible);
55
+ const [email, setEmail] = useState('');
56
+ const [busy, setBusy] = useState(false);
57
+ const [said, setSaid] = useState(saved ? 'Saved' : null);
58
+ const panel = useRef(null);
59
+ useEffect(() => {
60
+ panel.current?.focus();
61
+ const onKey = (e) => { if (e.key === 'Escape')
62
+ onClose(); };
63
+ document.addEventListener('keydown', onKey);
64
+ return () => document.removeEventListener('keydown', onKey);
65
+ }, [onClose]);
66
+ const changes = settingsChanges(s, { access, password, comments, commentsVisible: visible });
67
+ const dirty = Object.keys(changes).length > 0;
68
+ const save = async () => {
69
+ if (!dirty || busy)
70
+ return;
71
+ setBusy(true);
72
+ setSaid(null);
73
+ const r = await send(s.endpoint, changes);
74
+ setBusy(false);
75
+ if (!r.ok) {
76
+ setSaid(r.error);
77
+ return;
78
+ }
79
+ setS(r.settings);
80
+ setSaid('Saved');
81
+ // The page renders from these settings, so show it as it now is, and come back to this sheet.
82
+ try {
83
+ window.sessionStorage.setItem(REOPEN_KEY, kind);
84
+ }
85
+ catch { /* the reload still shows it */ }
86
+ window.location.reload();
87
+ };
88
+ const listChange = async (body) => {
89
+ if (busy)
90
+ return;
91
+ setBusy(true);
92
+ setSaid(null);
93
+ const r = await send(s.endpoint, body);
94
+ setBusy(false);
95
+ if (!r.ok) {
96
+ setSaid(r.error);
97
+ return;
98
+ }
99
+ setS(r.settings);
100
+ if (body.add)
101
+ setEmail('');
102
+ setSaid(body.add ? 'Added' : 'Removed');
103
+ };
104
+ const onAdd = (e) => {
105
+ e.preventDefault();
106
+ const v = email.trim();
107
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) {
108
+ setSaid('That is not an email address');
109
+ return;
110
+ }
111
+ void listChange({ add: [{ email: v }] });
112
+ };
113
+ const copy = async () => setSaid((await onCopy(s.link)) ? 'Link copied' : 'Could not copy the link');
114
+ const font = phone ? 16 : 14;
115
+ const style = {
116
+ ...vars,
117
+ position: 'fixed', zIndex: 61, boxSizing: 'border-box', background: 'var(--a-surface, var(--a-ground, #fff))', color: 'var(--a-strong, var(--a-ink, #111))',
118
+ border: '1px solid var(--a-line, rgba(127,127,127,0.3))', boxShadow: '0 12px 32px rgba(0,0,0,0.22)', fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
119
+ fontStyle: 'normal', fontWeight: 400, letterSpacing: 'normal', textTransform: 'none', outline: 'none', fontSize: font, lineHeight: 1.4, overflowY: 'auto',
120
+ ...(phone
121
+ ? { left: 8, right: 8, bottom: 'calc(env(safe-area-inset-bottom, 0px) + 8px)', borderRadius: 16, padding: '14px 14px 12px', maxHeight: '82vh' }
122
+ : { top: 'calc(env(safe-area-inset-top, 0px) + 56px)', right: 12, width: 360, borderRadius: 12, padding: '14px 14px 12px', maxHeight: 'calc(100vh - 72px)' }),
123
+ };
124
+ const h = { margin: '0 0 10px', fontSize: font + 1, fontWeight: 600 };
125
+ const legend = { padding: 0, margin: '0 0 6px', fontSize: 12, fontWeight: 600, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.06em' };
126
+ const fieldset = { border: 0, margin: '0 0 14px', padding: 0, minWidth: 0 };
127
+ const choice = { display: 'flex', alignItems: 'center', gap: 10, padding: phone ? '8px 2px' : '5px 2px', cursor: 'pointer' };
128
+ const btn = {
129
+ font: 'inherit', fontSize: font - 1, borderRadius: 8, padding: phone ? '9px 14px' : '6px 12px', cursor: 'pointer',
130
+ border: '1px solid var(--a-line, rgba(127,127,127,0.3))', background: 'transparent', color: 'inherit',
131
+ };
132
+ const primary = { ...btn, background: 'var(--a-accent, #111)', borderColor: 'var(--a-accent, #111)', color: 'var(--a-ground, #fff)', fontWeight: 600 };
133
+ const muted = { margin: '4px 0 0', fontSize: 12, opacity: 0.65 };
134
+ const radio = (name, value, current, set, label, note) => (_jsxs("label", { style: choice, "data-settings-choice": `${name}:${value}`, children: [_jsx("input", { type: "radio", name: name, value: value, checked: current === value, onChange: () => set(value), style: { width: 18, height: 18, margin: 0, accentColor: 'var(--a-accent, #111)' } }), _jsxs("span", { children: [label, note ? _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: note }) : null] })] }, value));
135
+ const share = (_jsxs(_Fragment, { children: [_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can open it" }), s.levels.map((l) => radio('access', l, access, setAccess, ACCESS_LABEL[l], l === 'freedom' ? 'Plus anyone on the list' : undefined))] }), access === 'public' ? (_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Password" }), _jsxs("label", { style: choice, "data-settings-choice": "password", children: [_jsx("input", { type: "checkbox", checked: password, onChange: (e) => setPassword(e.target.checked), style: { width: 18, height: 18, margin: 0, accentColor: 'var(--a-accent, #111)' } }), _jsxs("span", { children: ["Require a password", _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: "The link to send carries the key" })] })] })] })) : null, dirty || busy ? (_jsxs("div", { style: { display: 'flex', gap: 8, margin: '0 0 14px' }, children: [_jsx("button", { type: "button", "data-settings-save": true, onClick: () => void save(), disabled: busy, style: primary, children: busy ? 'Saving' : 'Save' }), _jsx("button", { type: "button", onClick: () => { setAccess(s.access); setPassword(s.password); }, disabled: busy, style: btn, children: "Undo" })] })) : null, s.list && s.access !== 'public' ? (_jsxs("fieldset", { style: fieldset, "data-settings-list": true, children: [_jsx("legend", { style: legend, children: `The list (${s.list.length})` }), s.list.length === 0 ? _jsx("p", { style: { ...muted, margin: '0 0 6px' }, children: "Nobody yet." }) : null, _jsx("ul", { style: { listStyle: 'none', margin: '0 0 8px', padding: 0 }, children: s.list.map((e) => (_jsxs("li", { "data-settings-list-entry": e.email, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', borderBottom: '1px solid var(--a-line, rgba(127,127,127,0.2))' }, children: [_jsx("span", { style: { flex: 1, minWidth: 0, overflowWrap: 'anywhere' }, children: e.name ? _jsxs(_Fragment, { children: [e.name, _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: e.email })] }) : e.email }), _jsx("button", { type: "button", "aria-label": `Remove ${e.email}`, onClick: () => void listChange({ remove: [e.email] }), disabled: busy, style: btn, children: "Remove" })] }, e.email))) }), _jsxs("form", { onSubmit: onAdd, style: { display: 'flex', gap: 8 }, children: [_jsx("input", { type: "email", inputMode: "email", autoComplete: "off", placeholder: "name@example.com", "aria-label": "Email to add", value: email, onChange: (e) => setEmail(e.target.value), style: { flex: 1, minWidth: 0, font: 'inherit', ...NO_ZOOM_FONT, padding: phone ? '8px 10px' : '6px 8px', borderRadius: 8, border: '1px solid var(--a-line, rgba(127,127,127,0.4))', background: 'transparent', color: 'inherit' } }), _jsx("button", { type: "submit", disabled: busy, style: btn, children: "Add" })] }), s.access === 'invite' ? null : _jsx("p", { style: muted, children: "Freedom accounts get in without being on the list." })] })) : null, _jsxs("div", { style: { margin: '0 0 14px' }, "data-settings-link": true, children: [_jsx("p", { style: legend, children: "The link to send" }), _jsxs("div", { style: { display: 'flex', gap: 8, alignItems: 'center' }, children: [_jsx("code", { style: { flex: 1, minWidth: 0, fontSize: 13, overflowWrap: 'anywhere' }, children: s.link }), _jsx("button", { type: "button", onClick: () => void copy(), style: btn, children: "Copy" })] })] }), s.facts.length ? (_jsx("dl", { style: { display: 'grid', gap: 8, margin: 0 }, "data-settings-facts": true, children: s.facts.map((f) => (_jsxs("div", { children: [_jsx("dt", { style: { fontSize: 12, fontWeight: 600, opacity: 0.7 }, children: f.label }), _jsx("dd", { style: { margin: 0, fontSize: 13, opacity: 0.85 }, children: f.text })] }, f.label))) })) : null] }));
136
+ const commentSheet = (_jsxs(_Fragment, { children: [_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can comment" }), ['anyone', 'signed-in', 'off'].map((m) => radio('comments', m, comments, setComments, COMMENTS_LABEL[m], m === 'anyone' && s.gated ? 'This page is private, so only signed-in readers it is open to can' : undefined))] }), comments !== 'off' ? (_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can read the comments" }), ['owner', 'readers'].map((v) => radio('visible', v, visible, setVisible, VISIBLE_LABEL[v], v === 'owner' ? 'Each commenter also sees their own' : undefined))] })) : null, dirty || busy ? (_jsxs("div", { style: { display: 'flex', gap: 8 }, children: [_jsx("button", { type: "button", "data-settings-save": true, onClick: () => void save(), disabled: busy, style: primary, children: busy ? 'Saving' : 'Save' }), _jsx("button", { type: "button", onClick: () => { setComments(s.comments); setVisible(s.commentsVisible); }, disabled: busy, style: btn, children: "Undo" })] })) : null] }));
137
+ return (_jsxs(_Fragment, { children: [_jsx("div", { "aria-hidden": true, "data-settings-scrim": true, onClick: onClose, style: { position: 'fixed', inset: 0, zIndex: 60, background: phone ? 'rgba(0,0,0,0.28)' : 'transparent' } }), _jsxs("div", { ref: panel, role: "dialog", "aria-modal": "true", "aria-label": kind === 'share' ? 'Share settings' : 'Comment settings', tabIndex: -1, "data-settings-sheet": kind, "data-nospeak": true, "data-comment-ui": true, style: style, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }, children: [_jsx("h2", { style: h, children: kind === 'share' ? 'Share settings' : 'Comment settings' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", style: { ...btn, border: 0, padding: '2px 6px', fontSize: 20, lineHeight: 1 }, children: "\u00D7" })] }), kind === 'share' ? share : commentSheet, _jsx("p", { role: "status", "aria-live": "polite", "data-settings-said": true, style: { margin: said ? '10px 0 0' : 0, fontSize: 13, minHeight: 0 }, children: said ?? '' })] })] }));
138
+ }
@@ -4,6 +4,7 @@ import { type ArtifactRecord, type ArtifactStore, type VersionEntry } from '../a
4
4
  import type { StateStore } from '../artifacts/state-store.js';
5
5
  import { type ArtifactAssets } from '../artifacts/assets.js';
6
6
  import type { BrandPack } from '../brand/pack.js';
7
+ import type { PageSettings, SettingsFact } from '../reader/settings-sheet.js';
7
8
  import { type Access, type AllowEntry } from '../artifacts/reader.js';
8
9
  import { type ReadersStore } from '../artifacts/readers-store.js';
9
10
  import { type ReaderEvent } from '../artifacts/read-alerts.js';
@@ -91,6 +92,12 @@ export type ArtifactRoutesConfig = {
91
92
  * event into the alerts worth sending). Awaited, and anything it throws is swallowed: a broken
92
93
  * sink never costs a reader their page or their record. Without it nothing extra is read. */
93
94
  onReaderEvent?: (event: ReaderEvent) => void | Promise<void>;
95
+ /** The read-only lines under the controls in the page menu's Share settings sheet (0.31.0), for
96
+ * a host that knows more than the package about how its pages are found: its robots.txt, who
97
+ * its sign-in admits. Given, its rows REPLACE the package's own two (what a stranger with the
98
+ * bare URL sees, and search engines). Called only for the owner and admins; a throw falls back
99
+ * to the package's rows. */
100
+ shareFacts?: (artifact: ArtifactRecord) => SettingsFact[] | Promise<SettingsFact[]>;
94
101
  };
95
102
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
96
103
  export declare const ARTIFACT_ID: RegExp;
@@ -208,6 +215,9 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
208
215
  access: Access | null;
209
216
  readers: AllowEntry[];
210
217
  }>>;
218
+ SETTINGS: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
219
+ error: string;
220
+ }> | NextResponse<PageSettings | null>>;
211
221
  READS: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
212
222
  error: string;
213
223
  }> | NextResponse<{
@@ -22,6 +22,9 @@ import { CommentLayer } from '../reader/comment-layer.js';
22
22
  import { ReaderWatch } from '../reader/reader-watch.js';
23
23
  import { UpdatedTime } from '../reader/updated-time.js';
24
24
  import { PageMenu } from '../reader/page-menu.js';
25
+ import { COMMENTS_MODES } from '../artifacts/comments.js';
26
+ import { newPageKey } from '../artifacts/settings.js';
27
+ import { unlockedUrl } from '../artifacts/unlock.js';
25
28
  import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
26
29
  import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
27
30
  import { readSecondsFor, wordCount } from '../artifacts/read-alerts.js';
@@ -145,6 +148,46 @@ export function createArtifactRoutes(config) {
145
148
  }
146
149
  return { pages, readers: count };
147
150
  }
151
+ /** The package's own read-only lines for the Share settings sheet, from the rule `serve` applies
152
+ * and the metadata it emits (every page is noindex, nofollow). */
153
+ function defaultFacts(a) {
154
+ const unfurl = 'The title, the summary and the share card';
155
+ const stranger = a.access
156
+ ? `${unfurl}, then a sign-in door. The body never reaches them.`
157
+ : a.password ? `${unfurl}, then a door asking for the key.` : 'The whole page. The bare link is all anyone needs.';
158
+ return [
159
+ { label: 'A stranger with the bare URL sees', text: stranger },
160
+ { label: 'Search engines', text: 'Hidden from search engines (noindex, nofollow). People find it only through a link someone sends.' },
161
+ ];
162
+ }
163
+ /** The page's settings as the sheets show them, or null: null for anyone but the owner and the
164
+ * admins, and on a store that cannot change them, so no one else's page carries the list or
165
+ * the key. The settings endpoint answers in this same shape. */
166
+ async function settingsFor(a, reader) {
167
+ if (!isAdmin(reader) || !store.setSettings)
168
+ return null;
169
+ const list = config.readers ? await config.readers.allowList(a.id).catch(() => []) : null;
170
+ let facts = defaultFacts(a);
171
+ if (config.shareFacts) {
172
+ try {
173
+ facts = await config.shareFacts(a);
174
+ }
175
+ catch { /* the package's own lines stand */ }
176
+ }
177
+ const on = !!a.comments && a.comments !== 'off' && !!a.state && Object.hasOwn(a.state.slots, COMMENTS_SLOT);
178
+ return {
179
+ endpoint: `/api/artifacts/${a.id}/settings`,
180
+ access: a.access ?? 'public',
181
+ levels: config.readers ? ['public', 'freedom', 'invite'] : ['public'],
182
+ password: !!a.password,
183
+ link: !a.access && a.password ? unlockedUrl(pageUrl(a.id), a.password) : pageUrl(a.id),
184
+ list: list ? list.map((e) => ({ email: e.email, ...(e.name ? { name: e.name } : {}) })) : null,
185
+ comments: config.state ? (on ? a.comments : 'off') : null,
186
+ commentsVisible: a.commentsVisible ?? 'owner',
187
+ gated: !!a.access,
188
+ facts,
189
+ };
190
+ }
148
191
  /** The page menu for one reader on one page. Everything an item needs is decided here, and only
149
192
  * what this reader may use is passed: the markdown source and the history list never reach a
150
193
  * reader who may not have them, so the browser has nothing to hide.
@@ -153,7 +196,7 @@ export function createArtifactRoutes(config) {
153
196
  * link, since only they hold it.
154
197
  * - Version history: whoever `history` lets see past versions (admins by default).
155
198
  * - Copy the markdown: the owner and admins. */
156
- function Menu({ a, reader, shutOut, allow, comments, history, viewing, source }) {
199
+ function Menu({ a, reader, shutOut, allow, comments, history, viewing, source, settings }) {
157
200
  const role = roleOf(reader);
158
201
  const admin = isAdmin(reader);
159
202
  const name = reader ? (firstName(reader, allow ?? []) ?? reader.email) : null;
@@ -162,7 +205,7 @@ export function createArtifactRoutes(config) {
162
205
  : a.password
163
206
  ? (admin ? { url: `${pageUrl(a.id)}?key=${encodeURIComponent(a.password)}`, title: a.title, note: 'The link carries the password.' } : null)
164
207
  : { url: pageUrl(a.id), title: a.title };
165
- return (_jsx(PageMenu, { role: role, name: name, shutOut: shutOut, share: share, comments: !!comments, signIn: !reader && signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, signOut: reader ? signOutUrl(a.id) : null, history: history && history.length > 1 && seesHistory(reader) && viewing ? { items: history, base: pagePath(a.id), viewing } : null, markdown: admin && source ? source : null }));
208
+ return (_jsx(PageMenu, { role: role, name: name, shutOut: shutOut, share: share, comments: !!comments, signIn: !reader && signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, signOut: reader ? signOutUrl(a.id) : null, history: history && history.length > 1 && seesHistory(reader) && viewing ? { items: history, base: pagePath(a.id), viewing } : null, markdown: admin && source ? source : null, settings: admin ? settings ?? null : null }));
166
209
  }
167
210
  /** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
168
211
  * whole paragraph is data-nospeak, and narrationText has no `to` to read. */
@@ -255,7 +298,7 @@ export function createArtifactRoutes(config) {
255
298
  // says who they are. Only a host that signs readers in can know one; the read never fails the page.
256
299
  const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
257
300
  if (!open) {
258
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: reader }), _jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
301
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: reader, settings: await settingsFor(a, reader) }), _jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
259
302
  }
260
303
  const page = await render(true, reader);
261
304
  if (n === null)
@@ -282,14 +325,14 @@ export function createArtifactRoutes(config) {
282
325
  else
283
326
  void flagged;
284
327
  }
285
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: reader, shutOut: d.why === 'not-allowed' }), header, d.why === 'not-allowed'
328
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: reader, shutOut: d.why === 'not-allowed', settings: await settingsFor(a, reader) }), header, d.why === 'not-allowed'
286
329
  ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: switchUrl(id) })
287
330
  : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
288
331
  }
289
332
  const r = reader;
290
333
  // The agreement comes before the body, every reader, once per page. No agreement, no body.
291
334
  if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
292
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: r, allow: allow }), header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: switchUrl(id) })] }));
335
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: r, allow: allow, settings: await settingsFor(a, r) }), header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: switchUrl(id) })] }));
293
336
  }
294
337
  const page = await render(false, r);
295
338
  if (n === null)
@@ -339,7 +382,7 @@ export function createArtifactRoutes(config) {
339
382
  const title = v.title ?? a.title;
340
383
  const summary = v.summary ?? a.summary;
341
384
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
342
- return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { n: n, at: v.at }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { n: n, at: v.at })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
385
+ return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { n: n, at: v.at }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { n: n, at: v.at })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
343
386
  }
344
387
  /** The page itself, shared by open and gated pages. */
345
388
  async function Body(a, { top, reader }) {
@@ -360,7 +403,7 @@ export function createArtifactRoutes(config) {
360
403
  const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
361
404
  const history = await historyOf(a.id);
362
405
  const current = a.version ?? (a.versions?.length ?? 0) + 1;
363
- return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, comments: mode !== 'off', history: history, viewing: current, source: isAdmin(reader) ? sourceOf(a.id, a, a.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
406
+ return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, comments: mode !== 'off', history: history, viewing: current, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, a, a.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
364
407
  const n = a.version ?? (a.versions?.length ?? 0) + 1;
365
408
  const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
366
409
  const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
@@ -501,6 +544,107 @@ export function createArtifactRoutes(config) {
501
544
  const readers = await config.readers.allow(id, add, remove);
502
545
  return NextResponse.json({ id, access, readers });
503
546
  }
547
+ /** GET|POST /api/artifacts/<id>/settings: the page menu's Share settings and Comment settings
548
+ * (0.31.0). Signed in as the owner or an admin of this host, by the reader grant; nobody else,
549
+ * and never the publish key, which belongs to a session rather than a person on the page.
550
+ * Everyone else is refused before the page is even looked up, so a refusal says nothing about
551
+ * it. A POST must come from this host's own pages (Origin, Sec-Fetch-Site) as JSON, which a
552
+ * cross-site form cannot send. Body, every field optional:
553
+ * { access: 'public'|'freedom'|'invite', password: 'on'|'off', comments: 'off'|'anyone'|'signed-in',
554
+ * commentsVisible: 'owner'|'readers', add: [{ email, name? }], remove: [email] }.
555
+ * Answers with the settings as the sheet shows them. */
556
+ async function SETTINGS(request, { params }) {
557
+ const no = (error, status) => NextResponse.json({ error }, { status, headers: { 'cache-control': 'no-store' } });
558
+ const reader = verifyGrant(readerSecret(), request.cookies.get(GRANT_COOKIE)?.value);
559
+ if (!reader)
560
+ return no('sign in to change this page', 401);
561
+ if (!isAdmin(reader))
562
+ return no('only the owner and this hub\'s admins change a page\'s settings', 403);
563
+ if (!store.setSettings)
564
+ return no('this store cannot change settings', 501);
565
+ const { id } = await params;
566
+ const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
567
+ if (!a)
568
+ return no(`no artifact with id ${id}`, 404);
569
+ const view = async (rec) => NextResponse.json(await settingsFor(rec, reader), { headers: { 'cache-control': 'no-store' } });
570
+ if (request.method === 'GET')
571
+ return view(a);
572
+ const origin = request.headers.get('origin');
573
+ const site = request.headers.get('sec-fetch-site');
574
+ if ((origin !== null && origin !== new URL(siteUrl).origin) || (site !== null && site !== 'same-origin'))
575
+ return no('wrong origin', 403);
576
+ if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
577
+ return no('send JSON', 415);
578
+ const raw = await request.text();
579
+ if (raw.length > 8192)
580
+ return no('too long', 413);
581
+ let b;
582
+ try {
583
+ b = JSON.parse(raw);
584
+ }
585
+ catch {
586
+ return no('the body is not JSON', 400);
587
+ }
588
+ if (!b || typeof b !== 'object' || Array.isArray(b))
589
+ return no('the body is an object', 400);
590
+ const KEYS = ['access', 'password', 'comments', 'commentsVisible', 'add', 'remove'];
591
+ const unknown = Object.keys(b).filter((k) => !KEYS.includes(k));
592
+ if (unknown.length)
593
+ return no(`unknown setting: ${unknown.join(', ')}`, 400);
594
+ const patch = {};
595
+ if (b.access !== undefined) {
596
+ if (b.access !== 'public' && !ACCESS_LEVELS.includes(b.access))
597
+ return no(`access must be one of: public, ${ACCESS_LEVELS.join(', ')}`, 400);
598
+ // Without a reader record a gated page is shut to everyone; the sheet never offers it.
599
+ if (b.access !== 'public' && !config.readers)
600
+ return no('this host keeps no reader list, so a page here is public or has a password', 400);
601
+ patch.access = b.access;
602
+ }
603
+ if (b.password !== undefined) {
604
+ if (b.password !== 'on' && b.password !== 'off')
605
+ return no('password is on or off', 400);
606
+ patch.password = b.password === 'off' ? null : (a.password ?? newPageKey());
607
+ }
608
+ if (b.comments !== undefined) {
609
+ if (!COMMENTS_MODES.includes(b.comments))
610
+ return no(`comments must be one of: ${COMMENTS_MODES.join(', ')}`, 400);
611
+ patch.comments = b.comments;
612
+ }
613
+ if (b.commentsVisible !== undefined) {
614
+ if (b.commentsVisible !== 'owner' && b.commentsVisible !== 'readers')
615
+ return no('commentsVisible is owner or readers', 400);
616
+ patch.commentsVisible = b.commentsVisible;
617
+ }
618
+ if ((patch.comments !== undefined || patch.commentsVisible !== undefined) && !config.state)
619
+ return no('this host keeps no comments', 400);
620
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
621
+ const add = b.add === undefined ? [] : b.add;
622
+ const remove = b.remove === undefined ? [] : b.remove;
623
+ if (!Array.isArray(add) || add.length > 50 || !add.every((e) => !!e && typeof e === 'object' && typeof e.email === 'string'
624
+ && EMAIL.test(e.email.trim()) && e.email.length <= 254
625
+ && (e.name === undefined || (typeof e.name === 'string' && (e.name).length <= 120))))
626
+ return no('add is a list of { email, name? } with a valid email', 400);
627
+ if (!Array.isArray(remove) || remove.length > 50 || !remove.every((e) => typeof e === 'string' && e.length <= 254))
628
+ return no('remove is a list of emails', 400);
629
+ if ((add.length || remove.length) && !config.readers)
630
+ return no('this host keeps no reader list', 400);
631
+ let rec = a;
632
+ if (Object.keys(patch).length) {
633
+ try {
634
+ rec = await store.setSettings(id, patch);
635
+ }
636
+ catch (e) {
637
+ return no(e instanceof Error ? e.message : 'could not change the settings', 409);
638
+ }
639
+ if (!rec)
640
+ return no(`no artifact with id ${id}`, 404);
641
+ }
642
+ if (add.length || remove.length) {
643
+ await config.readers.allow(id, add.map((e) => ({ email: e.email.trim(), ...(e.name ? { name: e.name.trim() } : {}) })), remove);
644
+ }
645
+ revalidatePath(pagePath(id));
646
+ return view(rec);
647
+ }
504
648
  /** GET /api/artifacts/<id>/reads, publish key: who read it, for how long, how far, and every
505
649
  * attempt to take it away or to open it without being let in. */
506
650
  async function READS(request, { params }) {
@@ -684,5 +828,5 @@ export function createArtifactRoutes(config) {
684
828
  revalidatePath(pagePath(id));
685
829
  return NextResponse.json({ deleted: true });
686
830
  }
687
- return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
831
+ return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, SETTINGS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
688
832
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.30.2",
3
+ "version": "0.31.1",
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",