@supersuit/artifacts 0.2.0 → 0.4.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.
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The notes widget in the reader's browser. One provider per page reads the page's state once
4
+ // (GET /api/artifacts/<id>/state), places every note under the heading it was left under, and
5
+ // sets aside the ones whose heading is gone. A small "note" control sits in each heading; the
6
+ // panel after the heading lists its notes and, when open, takes a new one.
7
+ //
8
+ // Everything drawn here carries data-nospeak: narration reads the prose, never the notes, and
9
+ // the read-along highlighter skips the same nodes. Notes are shown as text, never as markup.
10
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
11
+ import { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from '../artifacts/notes-place.js';
12
+ const NotesContext = createContext(null);
13
+ const isNote = (v) => !!v && typeof v === 'object' && typeof v.slug === 'string' &&
14
+ typeof v.heading === 'string' && typeof v.note === 'string';
15
+ function notesFrom(body) {
16
+ const slot = body?.slots?.[NOTES_SLOT];
17
+ if (!slot)
18
+ return [];
19
+ const rows = Array.isArray(slot.shared) ? slot.shared : Array.isArray(slot.mine) ? slot.mine.map((e) => ({ ...e, mine: true })) : [];
20
+ const out = [];
21
+ for (const r of rows) {
22
+ if (!isNote(r.value))
23
+ continue;
24
+ out.push({
25
+ id: String(r.id ?? ''), slug: r.value.slug, heading: r.value.heading, note: r.value.note,
26
+ name: r.mine ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', at: String(r.at ?? ''), mine: r.mine === true,
27
+ });
28
+ }
29
+ return out;
30
+ }
31
+ export function NotesProvider({ artifactId, headings, accent, children }) {
32
+ const endpoint = `/api/artifacts/${artifactId}/state`;
33
+ const [notes, setNotes] = useState([]);
34
+ const [off, setOff] = useState(false);
35
+ const [canWrite, setCanWrite] = useState(false);
36
+ const [signIn, setSignIn] = useState(null);
37
+ const [open, setOpen] = useState(null);
38
+ const [error, setError] = useState(null);
39
+ const [busy, setBusy] = useState(false);
40
+ const apply = useCallback((status, body) => {
41
+ if (status === 200) {
42
+ setNotes(notesFrom(body));
43
+ setCanWrite(body.canWrite === true);
44
+ setSignIn(typeof body.signIn === 'string' ? body.signIn : null);
45
+ setError(null);
46
+ return true;
47
+ }
48
+ if (status === 401) {
49
+ setCanWrite(false);
50
+ setSignIn(typeof body.signIn === 'string' ? body.signIn : null);
51
+ return false;
52
+ }
53
+ // No state store, no such page, or no slot: the page takes no notes here, so draw nothing.
54
+ if (status === 404 || status === 501) {
55
+ setOff(true);
56
+ return false;
57
+ }
58
+ setError(typeof body.error === 'string' ? body.error : 'that did not save; try again');
59
+ return false;
60
+ }, []);
61
+ const call = useCallback(async (init) => {
62
+ try {
63
+ const r = await fetch(endpoint, { credentials: 'same-origin', cache: 'no-store', ...init });
64
+ const body = (await r.json().catch(() => ({})));
65
+ return apply(r.status, body);
66
+ }
67
+ catch {
68
+ setError('that did not reach the page; try again');
69
+ return false;
70
+ }
71
+ }, [endpoint, apply]);
72
+ useEffect(() => { void call(); }, [call]);
73
+ const post = useCallback(async (slug, heading, note) => {
74
+ setBusy(true);
75
+ const done = await call({
76
+ method: 'POST', headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({ slot: NOTES_SLOT, op: 'append', value: { slug, heading, note } }),
78
+ });
79
+ setBusy(false);
80
+ if (done)
81
+ setOpen(null);
82
+ return done;
83
+ }, [call]);
84
+ const remove = useCallback(async (id) => {
85
+ await call({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: NOTES_SLOT, op: 'remove', entry: id }) });
86
+ }, [call]);
87
+ const placed = useMemo(() => placeNotes(headings, notes), [headings, notes]);
88
+ const value = { off, accent: accent ?? 'currentColor', ...placed, canWrite, signIn, open, setOpen, error, busy, post, remove };
89
+ return _jsx(NotesContext.Provider, { value: value, children: children });
90
+ }
91
+ /** The small control inside a heading. */
92
+ export function NoteToggle({ slug }) {
93
+ const c = useContext(NotesContext);
94
+ if (!c || c.off)
95
+ return null;
96
+ const n = c.bySlug[slug]?.length ?? 0;
97
+ return (_jsx("button", { type: "button", "data-nospeak": true, "data-note-toggle": slug, "aria-expanded": c.open === slug, onClick: () => c.setOpen(c.open === slug ? null : slug), className: "ml-3 inline-block rounded-full border px-2 py-0.5 align-middle font-sans text-[11px] font-medium uppercase tracking-[0.15em] opacity-70 transition-opacity hover:opacity-100", style: { borderColor: c.accent, color: c.accent }, children: n ? `note · ${n}` : 'note' }));
98
+ }
99
+ function NoteItem({ n, c, under }) {
100
+ return (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: c.accent }, children: [_jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: n.note }), _jsxs("span", { className: "block text-xs opacity-60", children: [n.name, under ? _jsxs(_Fragment, { children: [" \u00B7 under \u201C", n.heading, "\u201D"] }) : null, n.mine && n.id ? (_jsx("button", { type: "button", "data-note-remove": n.id, onClick: () => void c.remove(n.id), className: "ml-2 underline opacity-80 hover:opacity-100", children: "remove" })) : null] })] }));
101
+ }
102
+ function NoteForm({ slug, heading, c }) {
103
+ const [text, setText] = useState('');
104
+ const submit = async (e) => {
105
+ e.preventDefault();
106
+ const note = text.trim();
107
+ if (!note)
108
+ return;
109
+ if (await c.post(slug, heading, note))
110
+ setText('');
111
+ };
112
+ if (!c.canWrite) {
113
+ return c.signIn ? (_jsx("p", { className: "my-2 text-sm", children: _jsx("a", { href: c.signIn, className: "underline", style: { color: c.accent }, children: "Sign in to leave a note" }) })) : (_jsx("p", { className: "my-2 text-sm opacity-70", children: "Notes here are open to signed-in readers." }));
114
+ }
115
+ return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-[15px] text-[color:var(--a-strong)]" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: 'var(--a-on-accent, #111)' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
116
+ }
117
+ /** After each heading: its notes, and the box when its control is open. */
118
+ export function HeadingNotes({ slug, text }) {
119
+ const c = useContext(NotesContext);
120
+ if (!c || c.off)
121
+ return null;
122
+ const list = c.bySlug[slug] ?? [];
123
+ const isOpen = c.open === slug;
124
+ if (!list.length && !isOpen)
125
+ return null;
126
+ return (_jsxs("div", { "data-nospeak": true, "data-heading-notes": slug, className: "my-3", children: [list.length ? _jsx("ul", { className: "m-0 grid list-none gap-2 p-0", children: list.map((n) => _jsx(NoteItem, { n: n, c: c }, n.id)) }) : null, isOpen ? _jsx(NoteForm, { slug: slug, heading: text, c: c }) : null] }));
127
+ }
128
+ /** Where the ```notes fence sits: a line saying notes are on, and the notes whose heading is gone. */
129
+ export function NotesEarlier() {
130
+ const c = useContext(NotesContext);
131
+ if (!c || c.off)
132
+ return null;
133
+ return (_jsxs("section", { "data-nospeak": true, "data-artifact-notes": true, className: "my-8 text-sm", children: [_jsx("p", { className: "opacity-60", children: "Leave a note beside any heading." }), c.earlier.length ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "mt-4 text-[11px] uppercase tracking-[0.2em]", style: { color: c.accent }, children: "Notes on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 p-0", children: c.earlier.map((n) => _jsx(NoteItem, { n: n, c: c, under: true }, n.id)) })] })) : null] }));
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",