@supersuit/artifacts 0.29.0 → 0.30.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 +35 -0
- package/README.md +39 -3
- package/lib/reader/comment-layer.js +9 -12
- package/lib/reader/index.d.ts +3 -0
- package/lib/reader/index.js +3 -0
- package/lib/reader/page-bridge.d.ts +9 -0
- package/lib/reader/page-bridge.js +24 -0
- package/lib/reader/page-menu.d.ts +34 -0
- package/lib/reader/page-menu.js +181 -0
- package/lib/reader/version-history.d.ts +20 -0
- package/lib/reader/version-history.js +36 -28
- package/lib/routes/artifacts.d.ts +2 -2
- package/lib/routes/artifacts.js +52 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,41 @@
|
|
|
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.30.0
|
|
7
|
+
|
|
8
|
+
**Every page has a page menu: one small "⋯" button, top right, whose items depend on who you are
|
|
9
|
+
to the page.** Release 1 of the approved design. Things that floated around the page on their own
|
|
10
|
+
(the History link, the comments on/off switch, sign in) now also have one always-findable home.
|
|
11
|
+
|
|
12
|
+
- A 36px round button fixed top right on every page, past version and door, themed with the
|
|
13
|
+
page's variables, labelled "Page menu". It opens a sheet on a phone and a popover on a desk;
|
|
14
|
+
a tap outside or Escape closes it.
|
|
15
|
+
- First line, the role: "Not signed in", "Signed in as Dana", "… · not on this page's list" at a
|
|
16
|
+
gated door, "… · owner of this page" (`ownerEmail`), "… · admin of this hub" (`admins`).
|
|
17
|
+
- Items, absent (never greyed) when a role cannot use them: **Share** (the phone's share sheet,
|
|
18
|
+
else copy with "Link copied"; on `invite` pages with "Only people on the list can open it."; on
|
|
19
|
+
password pages for the owner and admins only, the link carrying `?key=`), **Show / Hide
|
|
20
|
+
comments** (only where shared comments are on, kept in sync with the comment panel's own toggle),
|
|
21
|
+
**Sign in** (back to the exact address, hash included) / **Sign out**, **Version history**
|
|
22
|
+
(whoever `history` allows, admins by default), **Copy the markdown** (owner and admins: the
|
|
23
|
+
published front matter and body, ready to republish).
|
|
24
|
+
- The History control left the "Version N · Updated" line, which stays for everyone; the version
|
|
25
|
+
list stays in the server markup, hidden, for readers with scripts off.
|
|
26
|
+
- The server passes the menu only what this reader may use: the markdown source, a keyed share
|
|
27
|
+
link and the version list never reach a page rendered for anyone else.
|
|
28
|
+
- New exports from `/reader`: `PageMenu`, `roleLine`, `menuItems`, `HistoryPanel`, `VersionList`,
|
|
29
|
+
`VersionHistory`, and the page bridge (`requestPins`, `announcePins`, `signInBack`).
|
|
30
|
+
- DETECTOR: open any page. No round "⋯" button in the top right means 0.29.0 or earlier.
|
|
31
|
+
- REMEDY: update the dependency. No config change; set `ownerEmail` and `admins` for the owner and
|
|
32
|
+
admin items, `signInOrigin` for Sign in.
|
|
33
|
+
- Tests: `reader/page-menu.test.tsx` (role line per role, items per role, share copies on a desk
|
|
34
|
+
and uses the sheet on a phone, a refused or hung share/clipboard still answers, invite note,
|
|
35
|
+
copy markdown, sign-in returns with the hash, history panel, closing, two-way sync with the
|
|
36
|
+
comment layer), `routes/page-menu.test.tsx` (role and items per role and access level, password
|
|
37
|
+
share only for admins, the markdown source and version list never handed to non-admins). The
|
|
38
|
+
0.15.0 golden was regenerated on purpose: the History control moved into the menu marker.
|
|
39
|
+
`test:packed` green, with the menu marker checked in the built fixture.
|
|
40
|
+
|
|
6
41
|
## 0.29.0
|
|
7
42
|
|
|
8
43
|
**The "comment can't be shown" notice is a readable card with a way in.** 0.28.0 drew it as a pill
|
package/README.md
CHANGED
|
@@ -114,10 +114,46 @@ hostname and pick by the `host` header.
|
|
|
114
114
|
body. The response carries the page's id and URL. Posting a file whose front matter carries
|
|
115
115
|
that `id:` republishes it in place, keeping every earlier version.
|
|
116
116
|
|
|
117
|
+
## The page menu
|
|
118
|
+
|
|
119
|
+
Every page, past version and door carries one small round **⋯** button in the top right corner
|
|
120
|
+
(`aria-label="Page menu"`), the same on every host. Tapping it opens a short menu: a sheet from
|
|
121
|
+
the bottom on a phone, a popover under the button on a desk. A tap outside, Escape, or choosing an
|
|
122
|
+
item closes it. It paints with the page's own theme variables, so it follows the pack and the
|
|
123
|
+
light or dark mode.
|
|
124
|
+
|
|
125
|
+
The first line says who the reader is to this page, and the items depend on it. **An item a role
|
|
126
|
+
cannot use is absent, never greyed out.**
|
|
127
|
+
|
|
128
|
+
| Role | Role line | Items |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| Visitor (signed out) | Not signed in | Share, Show / Hide comments, Sign in |
|
|
131
|
+
| Reader (signed in) | Signed in as Dana | Share, Show / Hide comments, Sign out |
|
|
132
|
+
| 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
|
+
| Admin (`admins`) | Signed in as Casey · admin of this hub | the owner's items |
|
|
135
|
+
|
|
136
|
+
- **Share** opens the phone's own share sheet where there is one, and otherwise copies the link and
|
|
137
|
+
says "Link copied". Public and `access: freedom` pages show it to everyone; `access: invite` pages
|
|
138
|
+
show it with the line "Only people on the list can open it."; a password page shows it to the
|
|
139
|
+
owner and admins only, with the password in the link (`?key=`), since only they hold it.
|
|
140
|
+
- **Show / Hide comments** appears only where shared comments are on, and is the same switch as
|
|
141
|
+
the comment panel's own toggle: moving either moves both.
|
|
142
|
+
- **Sign in** needs `signInOrigin` and returns to the exact address, hash included. **Sign out** is
|
|
143
|
+
this host's `/api/reader/leave` (the `LEAVE` handler).
|
|
144
|
+
- **Version history** is for whoever `history` lets see past versions (the owner and admins by
|
|
145
|
+
default) and opens the panel described below.
|
|
146
|
+
- **Copy the markdown** copies the page as published (an `id`, `title`, `summary`, `subtitle` and
|
|
147
|
+
`to` front matter, then the body), ready to paste into a session and republish onto the same page.
|
|
148
|
+
|
|
149
|
+
The server decides every item and hands the browser only what this reader may use: the markdown
|
|
150
|
+
source, a share link carrying a password, and the version list are never in a page rendered for
|
|
151
|
+
anyone else. `PageMenu`, `roleLine` and `menuItems` are exported from `/reader`.
|
|
152
|
+
|
|
117
153
|
## Version history
|
|
118
154
|
|
|
119
|
-
Every page names its version under the summary, "Version 4 · Updated <minute>
|
|
120
|
-
|
|
155
|
+
Every page names its version under the summary, "Version 4 · Updated <minute>", and the page
|
|
156
|
+
menu's **Version history** opens a panel listing every version newest first: its number, the minute it went live in
|
|
121
157
|
the reader's own time zone, and its change note. The panel is drawn over the page (a sheet on a
|
|
122
158
|
phone, a side panel on a desk), so opening it moves no text. Each past version is readable at
|
|
123
159
|
`/<id>/v/<n>`, read-only, under a banner naming it and linking the current page, behind exactly
|
|
@@ -125,7 +161,7 @@ the same password or confidential door as the page itself. A version that does n
|
|
|
125
161
|
404; on a shut page the door comes first, so it never says which versions exist.
|
|
126
162
|
|
|
127
163
|
**Past versions are for admins by default (0.21.0).** An earlier draft can hold what the current
|
|
128
|
-
one was edited to remove, so only the page's admins see the
|
|
164
|
+
one was edited to remove, so only the page's admins see Version history in the page menu or open
|
|
129
165
|
`/<id>/v/<n>`: the reader signed in as `ownerEmail` or any address in `admins` (case does not
|
|
130
166
|
matter). Everyone else still sees "Version N · Updated <minute>", and a past version is a 404 to
|
|
131
167
|
them. With neither configured, nobody does. `history: 'everyone'` in the config restores the old
|
|
@@ -24,6 +24,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
24
24
|
// overlay is portalled onto document.body so its coordinates are page coordinates.
|
|
25
25
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
26
26
|
import { createPortal } from 'react-dom';
|
|
27
|
+
import { PINS_SET, announcePins, signInBack } from './page-bridge.js';
|
|
27
28
|
import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
|
|
28
29
|
import { COMMENTS_SLOT } from '../artifacts/comments.js';
|
|
29
30
|
import { CommentCard } from './comment-card.js';
|
|
@@ -37,18 +38,6 @@ import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from '
|
|
|
37
38
|
const isValue = (v) => !!v && typeof v === 'object' && typeof v.body === 'string' && !!v.anchor && typeof v.anchor === 'object';
|
|
38
39
|
const LONG_PRESS_MS = 400;
|
|
39
40
|
const HASH = /^#comment-([A-Za-z0-9_-]{1,128})$/;
|
|
40
|
-
/** The sign-in link, returning to this exact address, hash included, so a reader who signs in to
|
|
41
|
-
* see a linked comment lands on it. */
|
|
42
|
-
function signInBack(signIn) {
|
|
43
|
-
try {
|
|
44
|
-
const u = new URL(signIn);
|
|
45
|
-
u.searchParams.set('to', window.location.href);
|
|
46
|
-
return u.toString();
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return signIn;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
41
|
/** The entry id a `#comment-<id>` hash names, or null. */
|
|
53
42
|
const commentHashId = (hash) => HASH.exec(hash)?.[1] ?? null;
|
|
54
43
|
const DRAG_PX = 8;
|
|
@@ -360,6 +349,14 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
360
349
|
return { placed, earlier };
|
|
361
350
|
// `tick` re-measures after a resize.
|
|
362
351
|
}, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
352
|
+
// The page menu's Show / Hide comments drives the same switch as the panel's toggle, and hears
|
|
353
|
+
// back whichever one moved it, so the two never disagree.
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
const onSet = (e) => setShowPins(e.detail === true);
|
|
356
|
+
window.addEventListener(PINS_SET, onSet);
|
|
357
|
+
return () => window.removeEventListener(PINS_SET, onSet);
|
|
358
|
+
}, []);
|
|
359
|
+
useEffect(() => { announcePins(showPins); }, [showPins]);
|
|
363
360
|
// Show the comment the address names once it has loaded: a reply is shown in its parent's thread.
|
|
364
361
|
// Held until the comments arrive; an id that is not among them (deleted, or not this reader's to
|
|
365
362
|
// see) says so in a line, rather than leaving the reader wondering why nothing happened.
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming } from './artifact-reader.js';
|
|
2
2
|
export { PlayerBar } from './player-bar.js';
|
|
3
3
|
export { CommentLayer } from './comment-layer.js';
|
|
4
|
+
export { PageMenu, roleLine, menuItems, type PageMenuProps, type PageRole } from './page-menu.js';
|
|
5
|
+
export { VersionHistory, HistoryPanel, VersionList, type HistoryItem } from './version-history.js';
|
|
6
|
+
export { requestPins, announcePins, signInBack, PINS_SET, PINS_STATE } from './page-bridge.js';
|
|
4
7
|
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
5
8
|
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
|
6
9
|
export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom, type TextIndex, type Placement } from './comment-place.js';
|
package/lib/reader/index.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifact-reader.js';
|
|
4
4
|
export { PlayerBar } from './player-bar.js';
|
|
5
5
|
export { CommentLayer } from './comment-layer.js';
|
|
6
|
+
export { PageMenu, roleLine, menuItems } from './page-menu.js';
|
|
7
|
+
export { VersionHistory, HistoryPanel, VersionList } from './version-history.js';
|
|
8
|
+
export { requestPins, announcePins, signInBack, PINS_SET, PINS_STATE } from './page-bridge.js';
|
|
6
9
|
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
7
10
|
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
|
8
11
|
export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The menu asks the comment layer to show (true) or hide (false) its pins. */
|
|
2
|
+
export declare const PINS_SET = "artifact:pins-set";
|
|
3
|
+
/** The comment layer says whether its pins are showing, on mount and on every change. */
|
|
4
|
+
export declare const PINS_STATE = "artifact:pins-state";
|
|
5
|
+
export declare function requestPins(show: boolean): void;
|
|
6
|
+
export declare function announcePins(show: boolean): void;
|
|
7
|
+
/** The sign-in link, returning to this exact address, hash included, so a reader who signs in
|
|
8
|
+
* lands where they were (a linked comment, a section). */
|
|
9
|
+
export declare function signInBack(signIn: string): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Small bridges between the page menu and the parts of the page it drives, so neither has to
|
|
2
|
+
// import the other. Both run in the same window; a window event is the whole wire.
|
|
3
|
+
/** The menu asks the comment layer to show (true) or hide (false) its pins. */
|
|
4
|
+
export const PINS_SET = 'artifact:pins-set';
|
|
5
|
+
/** The comment layer says whether its pins are showing, on mount and on every change. */
|
|
6
|
+
export const PINS_STATE = 'artifact:pins-state';
|
|
7
|
+
export function requestPins(show) {
|
|
8
|
+
window.dispatchEvent(new CustomEvent(PINS_SET, { detail: show }));
|
|
9
|
+
}
|
|
10
|
+
export function announcePins(show) {
|
|
11
|
+
window.dispatchEvent(new CustomEvent(PINS_STATE, { detail: show }));
|
|
12
|
+
}
|
|
13
|
+
/** The sign-in link, returning to this exact address, hash included, so a reader who signs in
|
|
14
|
+
* lands where they were (a linked comment, a section). */
|
|
15
|
+
export function signInBack(signIn) {
|
|
16
|
+
try {
|
|
17
|
+
const u = new URL(signIn);
|
|
18
|
+
u.searchParams.set('to', window.location.href);
|
|
19
|
+
return u.toString();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return signIn;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type HistoryItem } from './version-history.js';
|
|
2
|
+
export type PageRole = 'visitor' | 'reader' | 'owner' | 'admin';
|
|
3
|
+
export type PageMenuProps = {
|
|
4
|
+
role: PageRole;
|
|
5
|
+
/** What to call the reader in the role line: a first name, else their email. Null signed out. */
|
|
6
|
+
name: string | null;
|
|
7
|
+
/** The page is gated and this reader is not let in (they see its door). */
|
|
8
|
+
shutOut?: boolean;
|
|
9
|
+
/** The link to share, or null when this reader cannot share this page. `note` sits under it. */
|
|
10
|
+
share: {
|
|
11
|
+
url: string;
|
|
12
|
+
title: string;
|
|
13
|
+
note?: string;
|
|
14
|
+
} | null;
|
|
15
|
+
/** Shared comments are on here, so the pins can be shown or hidden. */
|
|
16
|
+
comments: boolean;
|
|
17
|
+
/** The sign-in authority's link for this page, when signed out on a host that signs readers in. */
|
|
18
|
+
signIn: string | null;
|
|
19
|
+
/** This host's sign-out link, when signed in. */
|
|
20
|
+
signOut: string | null;
|
|
21
|
+
/** Every version, when this reader may see past versions and there is more than one. */
|
|
22
|
+
history: {
|
|
23
|
+
items: HistoryItem[];
|
|
24
|
+
base: string;
|
|
25
|
+
viewing: number;
|
|
26
|
+
} | null;
|
|
27
|
+
/** The published source, for the owner and admins only. */
|
|
28
|
+
markdown: string | null;
|
|
29
|
+
};
|
|
30
|
+
/** The menu's first line: who this reader is to this page. */
|
|
31
|
+
export declare function roleLine(role: PageRole, name: string | null, shutOut?: boolean): string;
|
|
32
|
+
/** 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[];
|
|
34
|
+
export declare function PageMenu(props: PageMenuProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The page menu: one small round "⋯" button fixed in the top right of every artifact page, the
|
|
4
|
+
// same on every page and every host. Tapping it opens a short menu whose first line says who the
|
|
5
|
+
// reader is to this page ("Signed in as Robin · admin of this hub"), and whose items depend on
|
|
6
|
+
// that role. Items a role cannot use are absent, never greyed out.
|
|
7
|
+
//
|
|
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
|
|
10
|
+
// reader may have: the markdown source and the history list never reach a reader who may not use
|
|
11
|
+
// them, so nothing here has to hide anything.
|
|
12
|
+
//
|
|
13
|
+
// Rendered as a hidden marker on the server and on the first client render, so the markup always
|
|
14
|
+
// hydrates; after mount the button and its menu are portalled onto document.body carrying the
|
|
15
|
+
// page's theme variables, like the comment layer, so nothing on the page can paint over them. On
|
|
16
|
+
// a phone the menu is a sheet from the bottom; on a desk a popover under the button. A tap
|
|
17
|
+
// outside, Escape, or choosing an item closes it.
|
|
18
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
19
|
+
import { createPortal } from 'react-dom';
|
|
20
|
+
import { themeVarsAround } from './theme-vars.js';
|
|
21
|
+
import { HistoryPanel, VersionList } from './version-history.js';
|
|
22
|
+
import { PINS_STATE, requestPins, signInBack } from './page-bridge.js';
|
|
23
|
+
/** The menu's first line: who this reader is to this page. */
|
|
24
|
+
export function roleLine(role, name, shutOut = false) {
|
|
25
|
+
if (role === 'visitor' || !name)
|
|
26
|
+
return 'Not signed in';
|
|
27
|
+
if (role === 'admin')
|
|
28
|
+
return `Signed in as ${name} · admin of this hub`;
|
|
29
|
+
if (role === 'owner')
|
|
30
|
+
return `Signed in as ${name} · owner of this page`;
|
|
31
|
+
return shutOut ? `Signed in as ${name} · not on this page's list` : `Signed in as ${name}`;
|
|
32
|
+
}
|
|
33
|
+
/** The items this reader gets, in order. Exported so the server markup and tests can name them. */
|
|
34
|
+
export function menuItems(p) {
|
|
35
|
+
return [
|
|
36
|
+
p.share ? 'share' : null,
|
|
37
|
+
p.comments ? 'comments' : null,
|
|
38
|
+
p.history ? 'history' : null,
|
|
39
|
+
p.markdown !== null ? 'markdown' : null,
|
|
40
|
+
p.signOut ? 'sign-out' : p.signIn ? 'sign-in' : null,
|
|
41
|
+
].filter((x) => !!x);
|
|
42
|
+
}
|
|
43
|
+
const PHONE = '(max-width: 639px)';
|
|
44
|
+
const isPhone = () => (typeof window.matchMedia === 'function' ? window.matchMedia(PHONE).matches : window.innerWidth < 640);
|
|
45
|
+
const coarse = () => typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
|
|
46
|
+
/** Copy text, by the clipboard API where it exists, else the old textarea way. */
|
|
47
|
+
async function copyText(text) {
|
|
48
|
+
try {
|
|
49
|
+
if (navigator.clipboard?.writeText) {
|
|
50
|
+
// Bounded: a browser waiting on a permission it never asks for leaves the promise pending
|
|
51
|
+
// (seen in headless Chrome), and the reader would get no answer at all.
|
|
52
|
+
const done = await Promise.race([
|
|
53
|
+
navigator.clipboard.writeText(text).then(() => true, () => false),
|
|
54
|
+
new Promise((r) => setTimeout(() => r(false), 1200)),
|
|
55
|
+
]);
|
|
56
|
+
if (done)
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { /* fall through */ }
|
|
61
|
+
try {
|
|
62
|
+
const t = document.createElement('textarea');
|
|
63
|
+
t.value = text;
|
|
64
|
+
t.setAttribute('readonly', '');
|
|
65
|
+
t.style.position = 'fixed';
|
|
66
|
+
t.style.opacity = '0';
|
|
67
|
+
document.body.appendChild(t);
|
|
68
|
+
t.select();
|
|
69
|
+
const ok = document.execCommand?.('copy') ?? false;
|
|
70
|
+
t.remove();
|
|
71
|
+
return ok;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function PageMenu(props) {
|
|
78
|
+
const { role, name, shutOut, share, comments, signIn, signOut, history, markdown } = props;
|
|
79
|
+
const items = menuItems(props);
|
|
80
|
+
const [mounted, setMounted] = useState(false);
|
|
81
|
+
const [open, setOpen] = useState(false);
|
|
82
|
+
const [phone, setPhone] = useState(false);
|
|
83
|
+
const [vars, setVars] = useState({});
|
|
84
|
+
const [pins, setPins] = useState(true);
|
|
85
|
+
const [said, setSaid] = useState(null);
|
|
86
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
87
|
+
const marker = useRef(null);
|
|
88
|
+
const button = useRef(null);
|
|
89
|
+
const menu = useRef(null);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setVars(themeVarsAround(marker.current));
|
|
92
|
+
setMounted(true);
|
|
93
|
+
const onPins = (e) => setPins(e.detail === true);
|
|
94
|
+
window.addEventListener(PINS_STATE, onPins);
|
|
95
|
+
return () => window.removeEventListener(PINS_STATE, onPins);
|
|
96
|
+
}, []);
|
|
97
|
+
const close = useCallback((focus = true) => {
|
|
98
|
+
setOpen(false);
|
|
99
|
+
setSaid(null);
|
|
100
|
+
if (focus)
|
|
101
|
+
button.current?.focus();
|
|
102
|
+
}, []);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!open)
|
|
105
|
+
return;
|
|
106
|
+
setPhone(isPhone());
|
|
107
|
+
setVars(themeVarsAround(marker.current));
|
|
108
|
+
menu.current?.querySelector('[role="menuitem"]')?.focus();
|
|
109
|
+
const onKey = (e) => { if (e.key === 'Escape')
|
|
110
|
+
close(); };
|
|
111
|
+
const onDown = (e) => {
|
|
112
|
+
const t = e.target;
|
|
113
|
+
if (menu.current?.contains(t) || button.current?.contains(t))
|
|
114
|
+
return;
|
|
115
|
+
close(false);
|
|
116
|
+
};
|
|
117
|
+
document.addEventListener('keydown', onKey);
|
|
118
|
+
document.addEventListener('pointerdown', onDown);
|
|
119
|
+
return () => {
|
|
120
|
+
document.removeEventListener('keydown', onKey);
|
|
121
|
+
document.removeEventListener('pointerdown', onDown);
|
|
122
|
+
};
|
|
123
|
+
}, [open, close]);
|
|
124
|
+
const onShare = async () => {
|
|
125
|
+
if (!share)
|
|
126
|
+
return;
|
|
127
|
+
// A phone hands the link to its own share sheet (Messages, Mail, AirDrop); a desk copies it.
|
|
128
|
+
if (coarse() && typeof navigator.share === 'function') {
|
|
129
|
+
try {
|
|
130
|
+
await navigator.share({ title: share.title, url: share.url });
|
|
131
|
+
close();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
// The reader backed out of the sheet: leave the menu as it was. Any other refusal (no
|
|
136
|
+
// permission, an unsupported page) falls through to copying, so Share always answers.
|
|
137
|
+
if (e?.name === 'AbortError')
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
setSaid((await copyText(share.url)) ? 'Link copied' : 'Could not copy the link');
|
|
142
|
+
};
|
|
143
|
+
const onMarkdown = async () => {
|
|
144
|
+
if (markdown === null)
|
|
145
|
+
return;
|
|
146
|
+
setSaid((await copyText(markdown)) ? 'Markdown copied' : 'Could not copy the markdown');
|
|
147
|
+
};
|
|
148
|
+
const onPins = () => {
|
|
149
|
+
requestPins(!pins);
|
|
150
|
+
setPins(!pins);
|
|
151
|
+
close();
|
|
152
|
+
};
|
|
153
|
+
const onHistory = () => {
|
|
154
|
+
setOpen(false);
|
|
155
|
+
setHistoryOpen(true);
|
|
156
|
+
};
|
|
157
|
+
const line = roleLine(role, name, shutOut);
|
|
158
|
+
const itemStyle = {
|
|
159
|
+
display: 'block', width: '100%', boxSizing: 'border-box', textAlign: 'left', font: 'inherit', fontSize: phone ? 16 : 14,
|
|
160
|
+
color: 'inherit', background: 'none', border: 0, borderRadius: 8, padding: phone ? '12px 12px' : '8px 10px',
|
|
161
|
+
cursor: 'pointer', textDecoration: 'none', lineHeight: 1.3,
|
|
162
|
+
};
|
|
163
|
+
const sub = { display: 'block', marginTop: 2, fontSize: 12, opacity: 0.6 };
|
|
164
|
+
const panelStyle = {
|
|
165
|
+
...vars,
|
|
166
|
+
position: 'fixed', zIndex: 60, boxSizing: 'border-box', background: 'var(--a-surface, var(--a-ground, #fff))', color: 'var(--a-strong, var(--a-ink, #111))',
|
|
167
|
+
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',
|
|
168
|
+
fontStyle: 'normal', fontWeight: 400, letterSpacing: 'normal', textTransform: 'none', outline: 'none',
|
|
169
|
+
...(phone
|
|
170
|
+
? { left: 8, right: 8, bottom: 'calc(env(safe-area-inset-bottom, 0px) + 8px)', borderRadius: 16, padding: '6px 6px 8px', maxHeight: '70vh', overflowY: 'auto' }
|
|
171
|
+
: { top: 'calc(env(safe-area-inset-top, 0px) + 56px)', right: 12, width: 264, borderRadius: 12, padding: 6 }),
|
|
172
|
+
};
|
|
173
|
+
const ui = mounted ? (_jsxs("div", { "data-page-menu-ui": true, "data-nospeak": true, "data-comment-ui": true, style: vars, children: [_jsx("button", { ref: button, type: "button", "data-page-menu-button": true, "aria-label": "Page menu", "aria-haspopup": "menu", "aria-expanded": open, onClick: () => (open ? close() : (setSaid(null), setOpen(true))), style: {
|
|
174
|
+
position: 'fixed', zIndex: 45, top: 'calc(env(safe-area-inset-top, 0px) + 12px)', right: 12, width: 36, height: 36,
|
|
175
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, margin: 0, borderRadius: 999, cursor: 'pointer',
|
|
176
|
+
color: 'var(--a-ink, #111)', background: 'color-mix(in srgb, var(--a-ground, #fff) 82%, transparent)',
|
|
177
|
+
border: '1px solid var(--a-line, rgba(127,127,127,0.3))', boxShadow: '0 1px 4px rgba(0,0,0,0.12)',
|
|
178
|
+
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;
|
|
180
|
+
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
|
+
}
|
|
@@ -12,3 +12,23 @@ export declare function VersionHistory({ items, base, viewing }: {
|
|
|
12
12
|
/** The version this page shows. */
|
|
13
13
|
viewing: number;
|
|
14
14
|
}): import("react").JSX.Element;
|
|
15
|
+
/** Every version as plain links, hidden: server markup a reader with scripts off can still use. */
|
|
16
|
+
export declare function VersionList({ items, base, marker }: {
|
|
17
|
+
items: HistoryItem[];
|
|
18
|
+
base: string;
|
|
19
|
+
marker?: boolean;
|
|
20
|
+
}): import("react").JSX.Element;
|
|
21
|
+
/** The open list, portalled onto document.body. `anchor` is the control that opened it: its theme
|
|
22
|
+
* variables are carried onto the panel, and a tap on it is not a tap outside. */
|
|
23
|
+
export declare function HistoryPanel({ items, base, viewing, anchor, onClose, onOutside }: {
|
|
24
|
+
items: HistoryItem[];
|
|
25
|
+
base: string;
|
|
26
|
+
viewing: number;
|
|
27
|
+
anchor: {
|
|
28
|
+
current: HTMLElement | null;
|
|
29
|
+
};
|
|
30
|
+
/** Escape or the close button: closes and hands focus back. */
|
|
31
|
+
onClose: () => void;
|
|
32
|
+
/** A tap outside the panel and the anchor. */
|
|
33
|
+
onOutside: () => void;
|
|
34
|
+
}): import("react").ReactPortal | null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
3
|
-
// The page's history, opened from the "Version
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The page's history, opened from the page menu ("Version history"), or from the standalone
|
|
4
|
+
// History control a host may still place itself. The list renders
|
|
4
5
|
// through a portal on document.body, OUTSIDE the text flow, like the definition layer, so opening
|
|
5
6
|
// it cannot reflow a word of the page: on a phone it is a sheet pinned to the bottom, above the
|
|
6
7
|
// narration bar; on a desk it is a panel pinned to the right of the window. Escape, the close
|
|
@@ -17,44 +18,51 @@ const MARGIN = 16;
|
|
|
17
18
|
const VARS = ['--a-ground', '--a-ink', '--a-accent', '--a-line'];
|
|
18
19
|
export function VersionHistory({ items, base, viewing }) {
|
|
19
20
|
const [open, setOpen] = useState(false);
|
|
21
|
+
const button = useRef(null);
|
|
22
|
+
const close = useCallback(() => {
|
|
23
|
+
setOpen(false);
|
|
24
|
+
button.current?.focus();
|
|
25
|
+
}, []);
|
|
26
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { ref: button, type: "button", "data-version-history": true, "aria-haspopup": "dialog", "aria-expanded": open, onClick: () => setOpen((o) => !o), style: { font: 'inherit', color: 'inherit', background: 'none', border: 0, padding: 0, cursor: 'pointer', textDecoration: 'underline', textUnderlineOffset: '0.2em' }, children: "History" }), _jsx(VersionList, { items: items, base: base }), open ? _jsx(HistoryPanel, { items: items, base: base, viewing: viewing, anchor: button, onClose: close, onOutside: () => setOpen(false) }) : null] }));
|
|
27
|
+
}
|
|
28
|
+
/** Every version as plain links, hidden: server markup a reader with scripts off can still use. */
|
|
29
|
+
export function VersionList({ items, base, marker }) {
|
|
30
|
+
return (_jsx("span", { hidden: true, "data-version-list": true, ...(marker ? { 'data-version-history': '' } : {}), children: items.map((v) => (_jsx("a", { href: v.current ? base : `${base}/v/${v.version}`, children: `Version ${v.version}${v.note ? `: ${v.note}` : ''}` }, v.version))) }));
|
|
31
|
+
}
|
|
32
|
+
/** The open list, portalled onto document.body. `anchor` is the control that opened it: its theme
|
|
33
|
+
* variables are carried onto the panel, and a tap on it is not a tap outside. */
|
|
34
|
+
export function HistoryPanel({ items, base, viewing, anchor, onClose, onOutside }) {
|
|
20
35
|
const [phone, setPhone] = useState(false);
|
|
21
36
|
const [bottom, setBottom] = useState(MARGIN);
|
|
22
37
|
const [vars, setVars] = useState({});
|
|
23
38
|
const [zone, setZone] = useState('UTC');
|
|
24
|
-
const button = useRef(null);
|
|
25
39
|
const panel = useRef(null);
|
|
26
40
|
const measure = useCallback(() => {
|
|
27
|
-
const el =
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
const el = anchor.current;
|
|
42
|
+
if (el) {
|
|
43
|
+
const cs = getComputedStyle(el);
|
|
44
|
+
setVars({ ...Object.fromEntries(VARS.map((v) => [v, cs.getPropertyValue(v).trim()]).filter(([, x]) => x)), fontFamily: cs.fontFamily });
|
|
45
|
+
}
|
|
32
46
|
const isPhone = window.matchMedia ? window.matchMedia(PHONE).matches : window.innerWidth < 640;
|
|
33
47
|
setPhone(isPhone);
|
|
34
48
|
const bar = document.querySelector('[data-artifact-player]')?.getBoundingClientRect();
|
|
35
49
|
setBottom((bar && bar.top < window.innerHeight ? window.innerHeight - bar.top : 0) + MARGIN);
|
|
36
|
-
}, []);
|
|
37
|
-
const close = useCallback(() => {
|
|
38
|
-
setOpen(false);
|
|
39
|
-
button.current?.focus();
|
|
40
|
-
}, []);
|
|
50
|
+
}, [anchor]);
|
|
41
51
|
useEffect(() => {
|
|
42
52
|
setZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
43
53
|
}, []);
|
|
44
54
|
useEffect(() => {
|
|
45
|
-
if (!open)
|
|
46
|
-
return;
|
|
47
55
|
measure();
|
|
48
56
|
panel.current?.focus();
|
|
49
57
|
const onKey = (e) => {
|
|
50
58
|
if (e.key === 'Escape')
|
|
51
|
-
|
|
59
|
+
onClose();
|
|
52
60
|
};
|
|
53
61
|
const onDown = (e) => {
|
|
54
62
|
const t = e.target;
|
|
55
|
-
if (panel.current?.contains(t) ||
|
|
63
|
+
if (panel.current?.contains(t) || anchor.current?.contains(t))
|
|
56
64
|
return;
|
|
57
|
-
|
|
65
|
+
onOutside();
|
|
58
66
|
};
|
|
59
67
|
document.addEventListener('keydown', onKey);
|
|
60
68
|
document.addEventListener('pointerdown', onDown);
|
|
@@ -64,7 +72,7 @@ export function VersionHistory({ items, base, viewing }) {
|
|
|
64
72
|
document.removeEventListener('pointerdown', onDown);
|
|
65
73
|
window.removeEventListener('resize', measure);
|
|
66
74
|
};
|
|
67
|
-
}, [
|
|
75
|
+
}, [measure, onClose, onOutside, anchor]);
|
|
68
76
|
const style = {
|
|
69
77
|
...vars,
|
|
70
78
|
zIndex: 60,
|
|
@@ -85,13 +93,13 @@ export function VersionHistory({ items, base, viewing }) {
|
|
|
85
93
|
? { position: 'fixed', left: MARGIN, right: MARGIN, bottom: `calc(${bottom}px + env(safe-area-inset-bottom, 0px))`, maxHeight: '65vh' }
|
|
86
94
|
: { position: 'fixed', top: 80, right: 24, width: 380, maxHeight: 'calc(100vh - 120px)' }),
|
|
87
95
|
};
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}) })] }),
|
|
96
|
-
|
|
96
|
+
if (typeof document === 'undefined')
|
|
97
|
+
return null;
|
|
98
|
+
return createPortal(_jsxs("div", { ref: panel, "data-version-panel": true, role: "dialog", "aria-label": "Version history", tabIndex: -1, style: style, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px 8px' }, children: [_jsx("span", { style: { fontSize: 11, fontWeight: 500, letterSpacing: '0.2em', textTransform: 'uppercase', opacity: 0.6 }, children: "Version history" }), _jsx("button", { type: "button", "aria-label": "Close", onClick: onClose, style: { font: 'inherit', fontSize: 20, lineHeight: 1, color: 'inherit', background: 'none', border: 0, padding: 4, cursor: 'pointer', opacity: 0.6 }, children: "\u00D7" })] }), _jsx("ol", { style: { listStyle: 'none', margin: 0, padding: '0 8px 10px' }, children: items.map((v) => {
|
|
99
|
+
const here = v.version === viewing;
|
|
100
|
+
return (_jsx("li", { children: _jsxs("a", { href: v.current ? base : `${base}/v/${v.version}`, "aria-current": here ? 'page' : undefined, style: {
|
|
101
|
+
display: 'block', padding: '10px 10px', borderRadius: 8, color: 'inherit', textDecoration: 'none',
|
|
102
|
+
background: here ? 'color-mix(in srgb, var(--a-accent, #888) 14%, transparent)' : undefined,
|
|
103
|
+
}, children: [_jsxs("span", { style: { display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }, children: [_jsx("strong", { style: { fontWeight: 600 }, children: `Version ${v.version}` }), v.current ? _jsx("span", { style: { fontSize: 12, color: 'var(--a-accent)' }, children: "current" }) : null, _jsx("time", { dateTime: v.at, style: { fontSize: 12, opacity: 0.6 }, children: formatUpdated(v.at, zone) })] }), v.note ? _jsx("span", { style: { display: 'block', marginTop: 2, fontSize: 14, opacity: 0.85 }, children: v.note }) : null] }) }, v.version));
|
|
104
|
+
}) })] }), document.body);
|
|
97
105
|
}
|
|
@@ -4,7 +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 Access } from '../artifacts/reader.js';
|
|
7
|
+
import { type Access, type AllowEntry } from '../artifacts/reader.js';
|
|
8
8
|
import { type ReadersStore } from '../artifacts/readers-store.js';
|
|
9
9
|
import { type ReaderEvent } from '../artifacts/read-alerts.js';
|
|
10
10
|
import type { PersonalStore } from '../artifacts/personal-store.js';
|
|
@@ -206,7 +206,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
206
206
|
}> | NextResponse<{
|
|
207
207
|
id: string;
|
|
208
208
|
access: Access | null;
|
|
209
|
-
readers:
|
|
209
|
+
readers: AllowEntry[];
|
|
210
210
|
}>>;
|
|
211
211
|
READS: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
|
|
212
212
|
error: string;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -21,7 +21,7 @@ import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
|
21
21
|
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
|
-
import {
|
|
24
|
+
import { PageMenu } from '../reader/page-menu.js';
|
|
25
25
|
import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
|
|
26
26
|
import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
|
|
27
27
|
import { readSecondsFor, wordCount } from '../artifacts/read-alerts.js';
|
|
@@ -65,6 +65,14 @@ export function createArtifactRoutes(config) {
|
|
|
65
65
|
/** Whether this reader may see a page's past versions (config `history`). */
|
|
66
66
|
const seesHistory = (reader) => config.history === 'everyone' || (!!reader && admins.has(reader.email.trim().toLowerCase()));
|
|
67
67
|
const readerSecret = () => config.readerSecret?.();
|
|
68
|
+
/** Who this reader is to this host's pages: the page menu's role. Owner and admins share a menu. */
|
|
69
|
+
const roleOf = (reader) => {
|
|
70
|
+
if (!reader)
|
|
71
|
+
return 'visitor';
|
|
72
|
+
const email = reader.email.trim().toLowerCase();
|
|
73
|
+
return email === ownerEmail ? 'owner' : admins.has(email) ? 'admin' : 'reader';
|
|
74
|
+
};
|
|
75
|
+
const isAdmin = (reader) => roleOf(reader) === 'owner' || roleOf(reader) === 'admin';
|
|
68
76
|
const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
|
|
69
77
|
// Signing out alone cannot change the account: the authority still holds the Google session
|
|
70
78
|
// and vouches for it again on the next sign-in. This leaves AND sends them to choose one.
|
|
@@ -137,6 +145,25 @@ export function createArtifactRoutes(config) {
|
|
|
137
145
|
}
|
|
138
146
|
return { pages, readers: count };
|
|
139
147
|
}
|
|
148
|
+
/** The page menu for one reader on one page. Everything an item needs is decided here, and only
|
|
149
|
+
* what this reader may use is passed: the markdown source and the history list never reach a
|
|
150
|
+
* reader who may not have them, so the browser has nothing to hide.
|
|
151
|
+
* - Share: public and `freedom` pages for everyone; `invite` pages too, with the line that only
|
|
152
|
+
* the list can open it; a password page only for the owner and admins, with the key in the
|
|
153
|
+
* link, since only they hold it.
|
|
154
|
+
* - Version history: whoever `history` lets see past versions (admins by default).
|
|
155
|
+
* - Copy the markdown: the owner and admins. */
|
|
156
|
+
function Menu({ a, reader, shutOut, allow, comments, history, viewing, source }) {
|
|
157
|
+
const role = roleOf(reader);
|
|
158
|
+
const admin = isAdmin(reader);
|
|
159
|
+
const name = reader ? (firstName(reader, allow ?? []) ?? reader.email) : null;
|
|
160
|
+
const share = a.access
|
|
161
|
+
? { url: pageUrl(a.id), title: a.title, ...(a.access === 'invite' ? { note: 'Only people on the list can open it.' } : {}) }
|
|
162
|
+
: a.password
|
|
163
|
+
? (admin ? { url: `${pageUrl(a.id)}?key=${encodeURIComponent(a.password)}`, title: a.title, note: 'The link carries the password.' } : null)
|
|
164
|
+
: { 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 }));
|
|
166
|
+
}
|
|
140
167
|
/** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
|
|
141
168
|
* whole paragraph is data-nospeak, and narrationText has no `to` to read. */
|
|
142
169
|
function Kicker({ to, brand }) {
|
|
@@ -224,12 +251,12 @@ export function createArtifactRoutes(config) {
|
|
|
224
251
|
...(a.state || config.personal ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
|
|
225
252
|
];
|
|
226
253
|
const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
|
|
254
|
+
// Who is reading, when they happen to be signed in: their comments follow them, and the menu
|
|
255
|
+
// says who they are. Only a host that signs readers in can know one; the read never fails the page.
|
|
256
|
+
const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
227
257
|
if (!open) {
|
|
228
|
-
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_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 })] }));
|
|
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 })] }));
|
|
229
259
|
}
|
|
230
|
-
// Who is reading an open page, when they happen to be signed in: their comments follow them.
|
|
231
|
-
// Only a host that signs readers in can know one; the read never fails the page.
|
|
232
|
-
const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
233
260
|
const page = await render(true, reader);
|
|
234
261
|
if (n === null)
|
|
235
262
|
void store.bumpViews(id);
|
|
@@ -255,14 +282,14 @@ export function createArtifactRoutes(config) {
|
|
|
255
282
|
else
|
|
256
283
|
void flagged;
|
|
257
284
|
}
|
|
258
|
-
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
|
|
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'
|
|
259
286
|
? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: switchUrl(id) })
|
|
260
287
|
: _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
|
|
261
288
|
}
|
|
262
289
|
const r = reader;
|
|
263
290
|
// The agreement comes before the body, every reader, once per page. No agreement, no body.
|
|
264
291
|
if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
|
|
265
|
-
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: switchUrl(id) })] }));
|
|
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) })] }));
|
|
266
293
|
}
|
|
267
294
|
const page = await render(false, r);
|
|
268
295
|
if (n === null)
|
|
@@ -276,9 +303,18 @@ export function createArtifactRoutes(config) {
|
|
|
276
303
|
return null;
|
|
277
304
|
return store.history(id).catch(() => null);
|
|
278
305
|
}
|
|
279
|
-
/** "Version N · Updated <minute>
|
|
280
|
-
|
|
281
|
-
|
|
306
|
+
/** "Version N · Updated <minute>": the line under the summary. The way into the history is the
|
|
307
|
+
* page menu's Version history (0.30.0), for whoever `history` lets see it. */
|
|
308
|
+
function VersionLine({ n, at }) {
|
|
309
|
+
return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at })] }));
|
|
310
|
+
}
|
|
311
|
+
/** The source of one version as published: its front matter's identifying lines, then the body,
|
|
312
|
+
* so it pastes into a session and republishes onto the same page. Quoted as JSON strings, which
|
|
313
|
+
* are valid YAML. */
|
|
314
|
+
function sourceOf(id, m, markdown) {
|
|
315
|
+
const lines = [`id: ${id}`, `title: ${JSON.stringify(m.title)}`, ...(m.subtitle ? [`subtitle: ${JSON.stringify(m.subtitle)}`] : []),
|
|
316
|
+
`summary: ${JSON.stringify(m.summary)}`, ...(m.to ? [`to: ${JSON.stringify(m.to)}`] : [])];
|
|
317
|
+
return `---\n${lines.join('\n')}\n---\n\n${markdown}`;
|
|
282
318
|
}
|
|
283
319
|
/** The title block and the body, in the pack's Article when it has one. Without one this is
|
|
284
320
|
* exactly the markup every page had before packs could frame it. */
|
|
@@ -303,7 +339,7 @@ export function createArtifactRoutes(config) {
|
|
|
303
339
|
const title = v.title ?? a.title;
|
|
304
340
|
const summary = v.summary ?? a.summary;
|
|
305
341
|
const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
|
|
306
|
-
return (_jsxs(_Fragment, { children: [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, {
|
|
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 }) })] }));
|
|
307
343
|
}
|
|
308
344
|
/** The page itself, shared by open and gated pages. */
|
|
309
345
|
async function Body(a, { top, reader }) {
|
|
@@ -322,14 +358,16 @@ export function createArtifactRoutes(config) {
|
|
|
322
358
|
const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
|
|
323
359
|
// Shared comments need the page to take them AND a host that keeps answers; personal notes need neither.
|
|
324
360
|
const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
|
|
325
|
-
|
|
361
|
+
const history = await historyOf(a.id);
|
|
362
|
+
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 () => {
|
|
326
364
|
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
327
|
-
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, {
|
|
365
|
+
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] }));
|
|
328
366
|
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] }));
|
|
329
367
|
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
|
|
330
368
|
// Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
|
|
331
369
|
checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
|
|
332
|
-
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner:
|
|
370
|
+
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
333
371
|
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
334
372
|
recorded at publish, or there is none. */] }));
|
|
335
373
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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",
|