@supersuit/artifacts 0.30.2 → 0.31.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 +42 -0
- package/README.md +45 -2
- package/lib/artifacts/index.d.ts +2 -0
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/settings.d.ts +62 -0
- package/lib/artifacts/settings.js +86 -0
- package/lib/artifacts/store.d.ts +8 -0
- package/lib/artifacts/store.js +33 -18
- package/lib/reader/index.d.ts +1 -0
- package/lib/reader/index.js +1 -0
- package/lib/reader/page-menu.d.ts +5 -1
- package/lib/reader/page-menu.js +24 -3
- package/lib/reader/settings-sheet.d.ts +55 -0
- package/lib/reader/settings-sheet.js +138 -0
- package/lib/routes/artifacts.d.ts +10 -0
- package/lib/routes/artifacts.js +152 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,48 @@
|
|
|
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.0
|
|
7
|
+
|
|
8
|
+
**The owner and admins change a page's share and comment settings from the page menu, and a later
|
|
9
|
+
publish of the same file no longer undoes it.** Release 2 of the page menu design. Until now these
|
|
10
|
+
settings changed only by editing front matter and republishing, so an admin on a phone could see a
|
|
11
|
+
page's settings (on hosts that drew their own panel) but not change them.
|
|
12
|
+
|
|
13
|
+
- Two items for the owner and the hub's admins: **Share settings** (who can open it: anyone with the
|
|
14
|
+
link, any Freedom account, only the list; the list, add or remove an email; a password on or off,
|
|
15
|
+
with the link carrying the key and a Copy button; read-only lines on what a stranger and search
|
|
16
|
+
engines see) and **Comment settings** (who can comment: anyone, signed-in readers, nobody; who
|
|
17
|
+
reads them: the owner and admins, or every reader). Each opens a sheet on a phone, a panel on a
|
|
18
|
+
desk. A save reloads the page and reopens the sheet saying "Saved".
|
|
19
|
+
- `GET|POST /api/artifacts/<id>/settings` (`SETTINGS`): the reader grant naming `ownerEmail` or
|
|
20
|
+
one of `admins`, nobody else (401 signed out, 403 otherwise, before the page is looked up; the
|
|
21
|
+
publish key is not accepted). A POST must be same-origin JSON. Validated; unknown fields refused.
|
|
22
|
+
- `ArtifactStore.setSettings(id, patch)`, implemented by `createArtifactStore`: access, password,
|
|
23
|
+
comments and their visibility in place, the comments slot added to or removed from `state`, no
|
|
24
|
+
new version.
|
|
25
|
+
- **Settings changed on the page win.** Each publish records what the file said about `access:`,
|
|
26
|
+
`password:` (as a hash), `comments:` and `comments_visible:` (`declared`); the next publish
|
|
27
|
+
changes a setting only when the file's line differs from that. Access already ignored a file that
|
|
28
|
+
named none; this extends the idea to a line the file still carries, and to the password and
|
|
29
|
+
comments. `settleOnPublish` and `applySettingsPatch` are exported.
|
|
30
|
+
- `shareFacts` (route config): a host's own read-only rows for the Share settings sheet, replacing
|
|
31
|
+
the package's two.
|
|
32
|
+
- DETECTOR: signed in as an admin, open the page menu. No "Share settings" item means an older
|
|
33
|
+
version, or a store without `setSettings`. A host route file is needed at
|
|
34
|
+
`app/api/artifacts/[id]/settings/route.ts`; without it a save answers 404.
|
|
35
|
+
- REMEDY: update the dependency and add the route file (`GET` and `POST` delegating to `SETTINGS`).
|
|
36
|
+
A host that drew its own share-settings panel on the page can remove it and pass its facts
|
|
37
|
+
through `shareFacts`. Pages published before 0.31.0 behave as before until their next publish.
|
|
38
|
+
- Tests: `artifacts/settings.test.ts` (the page-wins rule, every setting, legacy records),
|
|
39
|
+
`artifacts/store.test.ts` (setSettings round trips; a republish of the same file keeps the page's
|
|
40
|
+
change; a changed line wins), `routes/settings.test.tsx` (visitor, reader, owner, admin; CSRF;
|
|
41
|
+
validation; each setting round-trips; the list and key handed to nobody else; menu items by role;
|
|
42
|
+
`shareFacts`), `reader/settings-sheet.test.tsx` (sheets show current values, a save posts only
|
|
43
|
+
what moved, the list, reopen after reload), and `check-packed` (the tarball's endpoint, items and a
|
|
44
|
+
password set from the menu shutting the page). Driven in headless Chrome at 390x844 and 1280x800
|
|
45
|
+
against the packed fixture: open the menu, Share settings, change access, save, reopen, ⋯ still
|
|
46
|
+
reachable.
|
|
47
|
+
|
|
6
48
|
## 0.30.2
|
|
7
49
|
|
|
8
50
|
**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
|
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -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. */
|
package/lib/artifacts/store.js
CHANGED
|
@@ -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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
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, ...
|
|
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,
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/reader/index.js
CHANGED
|
@@ -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;
|
package/lib/reader/page-menu.js
CHANGED
|
@@ -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.
|
|
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<{
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.31.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",
|