@rathnasgala/theme 0.0.20 → 2.0.2

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.
@@ -1,264 +1,155 @@
1
- /**
2
- * The conversation under an article.
3
- *
4
- * Built as an island rather than by hand-writing DOM, because the state here is real: a thread
5
- * five deep, a page of roots with more behind a cursor, a reply form belonging to one comment, an
6
- * edit in progress, and a reader who may sign in halfway through. The imperative version rebuilt
7
- * the whole region from a fresh fetch after every write, which is how a posted comment could take
8
- * a browser cache's word for it and not appear at all — and a comment that is not rendered has no
9
- * Reply button, so the thread died with it.
10
- *
11
- * The article itself is never touched. Eleventy renders the post and everything search engines
12
- * read; this mounts only into the comments container, which was always filled from the API and so
13
- * has nothing to lose by being rendered here.
14
- */
15
- import { h, render } from './vendor/preact.js';
16
- import { useCallback, useEffect, useMemo, useState } from './vendor/hooks.js';
17
- import htm from './vendor/htm.js';
18
- // Importing the signals integration is what lets a component re-render when it reads `.value`.
19
- import './vendor/signals.js';
20
- import {
21
- engagementErrorMessage, requestSignIn, sendEngagementWrite, sessionUser,
22
- } from './engagement-transport.js';
1
+ import { engagementErrorMessage, requestSignIn, sendEngagementWrite, sessionUser } from './engagement-transport.js';
23
2
 
24
- const html = htm.bind(h);
25
-
26
- /** The server's own limit. A reply deeper than this is refused, so it is not offered. */
27
3
  const MAXIMUM_DEPTH = 5;
28
-
29
- const isoTime = (value) => {
30
- const at = Date.parse(value);
31
- return Number.isFinite(at) ? new Date(at).toLocaleDateString() : '';
4
+ const element = (name, className, text) => {
5
+ const node = document.createElement(name);
6
+ if (className) node.className = className;
7
+ if (text !== undefined) node.textContent = text;
8
+ return node;
32
9
  };
33
10
 
34
- /** Roots in order, each followed by its own descendants, from one flat API page. */
35
- function toThreads(items) {
36
- const byParent = new Map();
11
+ function threads(items) {
12
+ const children = new Map();
37
13
  for (const item of items) {
38
- const key = item.parentCommentId ?? '';
39
- if (!byParent.has(key)) byParent.set(key, []);
40
- byParent.get(key).push(item);
14
+ const parent = item.parentCommentId ?? '';
15
+ if (!children.has(parent)) children.set(parent, []);
16
+ children.get(parent).push(item);
41
17
  }
42
- const build = (parentId, depth) => (byParent.get(parentId) ?? []).map((comment) => ({
43
- ...comment,
44
- depth,
45
- replies: build(comment.commentId, depth + 1),
18
+ const build = (parent, depth) => (children.get(parent) ?? []).map((item) => ({
19
+ ...item, depth, replies: build(item.commentId, depth + 1),
46
20
  }));
47
21
  return build('', 0);
48
22
  }
49
23
 
50
- function Comment({ comment, reader, onReply, onEdit, onDelete, busy }) {
51
- const [replying, setReplying] = useState(false);
52
- const [editing, setEditing] = useState(false);
53
- const [draft, setDraft] = useState('');
54
- const mine = !comment.deleted && reader && comment.author?.userId === reader.id;
55
- const canReply = !comment.deleted && comment.depth < MAXIMUM_DEPTH;
56
-
57
- const submitReply = async (event) => {
58
- event.preventDefault();
59
- if (!draft.trim()) return;
60
- const posted = await onReply(comment.commentId, draft.trim());
61
- if (posted) { setDraft(''); setReplying(false); }
62
- };
63
-
64
- const submitEdit = async (event) => {
65
- event.preventDefault();
66
- if (!draft.trim()) return;
67
- const saved = await onEdit(comment.commentId, draft.trim());
68
- if (saved) setEditing(false);
69
- };
70
-
71
- return html`
72
- <li class="gala-comment" data-comment-id=${comment.commentId} data-depth=${comment.depth}>
73
- <p class="gala-comment__meta">
74
- <strong>${comment.author?.displayName ?? '[deleted]'}</strong>
75
- ${comment.createdAt && html`<time datetime=${comment.createdAt}>${isoTime(comment.createdAt)}</time>`}
76
- ${comment.editedAt && html`<span class="gala-comment__edited">edited</span>`}
77
- ${comment.pending && html`<span class="gala-comment__pending">sending…</span>`}
78
- </p>
79
-
80
- ${editing
81
- ? html`<form class="gala-comment__form" onSubmit=${submitEdit}>
82
- <label class="gala-visually-hidden" for=${`edit-${comment.commentId}`}>Edit your comment</label>
83
- <textarea id=${`edit-${comment.commentId}`} rows="3" value=${draft}
84
- onInput=${(e) => setDraft(e.target.value)}></textarea>
85
- <div class="gala-comment__controls">
86
- <button type="submit" disabled=${busy}>Save</button>
87
- <button type="button" onClick=${() => setEditing(false)}>Cancel</button>
88
- </div>
89
- </form>`
90
- : html`<p class="gala-comment__body">${comment.deleted ? '[deleted]' : comment.body}</p>`}
91
-
92
- ${!comment.deleted && html`
93
- <div class="gala-comment-actions">
94
- ${canReply && html`<button type="button" data-reply-comment=${comment.commentId}
95
- onClick=${() => { setReplying((open) => !open); setDraft(''); }}>Reply</button>`}
96
- ${mine && html`<button type="button" data-edit-comment=${comment.commentId}
97
- onClick=${() => { setEditing(true); setDraft(comment.body ?? ''); }}>Edit</button>`}
98
- ${mine && html`<button type="button" data-delete-comment=${comment.commentId}
99
- onClick=${() => onDelete(comment.commentId)} disabled=${busy}>Delete</button>`}
100
- </div>`}
101
-
102
- ${replying && html`
103
- <form class="gala-comment__form" onSubmit=${submitReply}>
104
- <label class="gala-visually-hidden" for=${`reply-${comment.commentId}`}>
105
- Reply to ${comment.author?.displayName ?? 'this comment'}
106
- </label>
107
- <textarea id=${`reply-${comment.commentId}`} rows="3" value=${draft} autofocus
108
- placeholder="Write a reply" onInput=${(e) => setDraft(e.target.value)}></textarea>
109
- <div class="gala-comment__controls">
110
- <button type="submit" disabled=${busy || !draft.trim()}>Post reply</button>
111
- <button type="button" onClick=${() => setReplying(false)}>Cancel</button>
112
- </div>
113
- </form>`}
114
-
115
- ${comment.replies.length > 0 && html`
116
- <ol class="gala-comment-replies">
117
- ${comment.replies.map((reply) => html`
118
- <${Comment} key=${reply.commentId} comment=${reply} reader=${reader} busy=${busy}
119
- onReply=${onReply} onEdit=${onEdit} onDelete=${onDelete} />`)}
120
- </ol>`}
121
- </li>`;
122
- }
123
-
124
- function Comments({ endpoint }) {
125
- // Reading `.value` during render subscribes this component to the signal, so a sign-in
126
- // anywhere on the page re-renders the conversation without anything having to tell it.
127
- const reader = sessionUser.value;
128
- const [items, setItems] = useState([]);
129
- const [cursor, setCursor] = useState(null);
130
- const [total, setTotal] = useState(0);
131
- const [status, setStatus] = useState('');
132
- const [busy, setBusy] = useState(false);
133
- const [draft, setDraft] = useState('');
134
- const [loading, setLoading] = useState(true);
24
+ function commentsController(root, endpoint) {
25
+ const state = { items: [], cursor: null, total: 0, busy: false, status: '', phase: 'loading' };
26
+ const articleId = /\/v1\/articles\/([^/]+)\/engagement/.exec(endpoint)?.[1] ?? '';
27
+ const statusNode = root.closest('.gala-conversation')?.querySelector('[data-engagement-status]');
135
28
 
136
- /* `fresh` skips the browser cache. The endpoint is `max-age=60, public`, which is right for a
137
- cold visit and wrong straight after a write: the browser would answer from its own cache with
138
- the state from before the reader wrote anything. */
139
- const load = useCallback(async (nextCursor = '', { append = false, fresh = false } = {}) => {
29
+ async function load(nextCursor = '', { append = false, fresh = false } = {}) {
140
30
  const url = new URL(endpoint);
141
31
  if (nextCursor) url.searchParams.set('commentsCursor', nextCursor);
142
32
  const response = await fetch(url, {
143
- headers: { Accept: 'application/json' },
144
- credentials: 'omit',
145
- cache: fresh ? 'no-store' : 'default',
33
+ headers: { Accept: 'application/json' }, credentials: 'omit', cache: fresh ? 'no-store' : 'default',
146
34
  });
147
35
  if (!response.ok) throw new Error(`Comments returned HTTP ${response.status}`);
148
- const payload = await response.json();
149
- const page = payload?.data?.comments;
150
- if (!page) throw new TypeError('Comment page is invalid');
151
- setItems((current) => (append ? [...current, ...page.items] : page.items));
152
- setCursor(page.nextCursor ?? null);
153
- if (Number.isSafeInteger(page.totalCount)) setTotal(page.totalCount);
154
- return page;
155
- }, [endpoint]);
156
-
157
- useEffect(() => {
158
- let live = true;
159
- load().catch(() => { if (live) setStatus('Comments are temporarily unavailable.'); })
160
- .finally(() => { if (live) setLoading(false); });
161
- return () => { live = false; };
162
- }, [load]);
163
-
164
- // Signing in changes what the reader may do, not what the article says, so only re-read when
165
- // the reader actually changes.
166
- useEffect(() => {
167
- if (!reader) return undefined;
168
- load('', { fresh: true }).catch(() => {});
169
- return undefined;
170
- }, [reader?.id]);
36
+ const page = (await response.json())?.data?.comments;
37
+ if (!page || !Array.isArray(page.items)) throw new TypeError('Comment page is invalid');
38
+ state.items = append ? [...state.items, ...page.items] : page.items;
39
+ state.cursor = page.nextCursor ?? null;
40
+ if (Number.isSafeInteger(page.totalCount)) state.total = page.totalCount;
41
+ }
171
42
 
172
- const write = useCallback(async (operation, payload, optimistic) => {
173
- if (!reader) { requestSignIn({ kind: 'comment' }); return false; }
174
- setBusy(true);
175
- setStatus('');
176
- if (optimistic) setItems((current) => [optimistic, ...current]);
43
+ async function write(operation, payload) {
44
+ if (!sessionUser.value) { requestSignIn({ kind: 'comment' }); return false; }
45
+ state.busy = true; state.status = ''; render();
177
46
  try {
178
47
  await sendEngagementWrite(operation, payload);
179
- /* Re-read past the cache so what is on screen is what the server actually holds — the
180
- optimistic row above is a promise to the reader, not a source of truth. */
181
48
  await load('', { fresh: true });
182
49
  return true;
183
50
  } catch (error) {
184
- if (optimistic) {
185
- setItems((current) => current.filter((item) => item.commentId !== optimistic.commentId));
186
- }
187
- setStatus(engagementErrorMessage(error.message));
51
+ state.status = engagementErrorMessage(error.message);
188
52
  return false;
189
- } finally {
190
- setBusy(false);
191
- }
192
- }, [reader?.id, load]);
193
-
194
- const articleId = useMemo(() => {
195
- const match = /\/v1\/articles\/([^/]+)\/engagement/.exec(endpoint);
196
- return match ? match[1] : '';
197
- }, [endpoint]);
53
+ } finally { state.busy = false; render(); }
54
+ }
198
55
 
199
- const post = async (event) => {
200
- event.preventDefault();
201
- const body = draft.trim();
202
- if (!body) return;
203
- const posted = await write('comment.create', { articleId, body }, {
204
- commentId: `pending-${crypto.randomUUID()}`,
205
- parentCommentId: null,
206
- body,
207
- depth: 0,
208
- createdAt: new Date().toISOString(),
209
- author: { userId: reader?.id, displayName: reader?.displayName },
210
- pending: true,
56
+ function form(label, initial, submit) {
57
+ const formNode = element('form', 'gala-comment__form');
58
+ const field = element('textarea');
59
+ field.rows = 3; field.value = initial; field.placeholder = label; field.setAttribute('aria-label', label);
60
+ const send = element('button', '', 'Post'); send.type = 'submit'; send.disabled = state.busy;
61
+ formNode.append(field, send);
62
+ formNode.addEventListener('submit', async (event) => {
63
+ event.preventDefault();
64
+ const body = field.value.trim();
65
+ if (body && await submit(body)) field.value = '';
211
66
  });
212
- if (posted) setDraft('');
213
- };
214
-
215
- const reply = (parentCommentId, body) =>
216
- write('comment.create', { articleId, parentCommentId, body });
217
- const edit = (commentId, body) => write('comment.edit', { articleId, commentId, body });
218
- const remove = (commentId) => write('comment.delete', { articleId, commentId });
219
-
220
- const threads = useMemo(() => toThreads(items), [items]);
221
-
222
- return html`
223
- <section class="gala-comments-island" aria-label="Comments">
224
- <h2 class="gala-comments__heading">${total === 1 ? '1 comment' : `${total} comments`}</h2>
225
-
226
- ${reader
227
- ? html`<form class="gala-comment__form" onSubmit=${post}>
228
- <label class="gala-visually-hidden" for="gala-new-comment">Add a comment</label>
229
- <textarea id="gala-new-comment" rows="3" value=${draft} placeholder="Add a comment"
230
- onInput=${(e) => setDraft(e.target.value)}></textarea>
231
- <button type="submit" disabled=${busy || !draft.trim()}>Post comment</button>
232
- </form>`
233
- : html`<p class="gala-comments__prompt">
234
- <button type="button" onClick=${() => requestSignIn({ kind: 'comment' })}>
235
- Sign in to join the conversation
236
- </button>
237
- </p>`}
67
+ return formNode;
68
+ }
238
69
 
239
- ${status && html`<p class="gala-comments__status" role="status">${status}</p>`}
70
+ function commentNode(comment) {
71
+ const item = element('li', 'gala-comment');
72
+ item.dataset.commentId = comment.commentId; item.dataset.depth = comment.depth;
73
+ const meta = element('p', 'gala-comment__meta');
74
+ meta.append(element('strong', '', comment.author?.displayName ?? '[deleted]'));
75
+ if (comment.createdAt) {
76
+ const time = element('time', '', new Date(comment.createdAt).toLocaleDateString());
77
+ time.dateTime = comment.createdAt; meta.append(time);
78
+ }
79
+ item.append(meta, element('p', 'gala-comment__body', comment.deleted ? '[deleted]' : comment.body));
80
+ if (!comment.deleted) {
81
+ const actions = element('div', 'gala-comment-actions');
82
+ if (comment.depth < MAXIMUM_DEPTH) {
83
+ const reply = element('button', '', 'Reply'); reply.type = 'button'; reply.dataset.replyComment = comment.commentId;
84
+ reply.addEventListener('click', () => {
85
+ const existing = item.querySelector(':scope > .gala-comment__form');
86
+ if (existing) { existing.remove(); return; }
87
+ actions.after(form(`Reply to ${comment.author?.displayName ?? 'this comment'}`, '',
88
+ (body) => write('comment.create', { articleId, parentCommentId: comment.commentId, body })));
89
+ }); actions.append(reply);
90
+ }
91
+ const mine = sessionUser.value && comment.author?.userId === sessionUser.value.id;
92
+ if (mine) {
93
+ const edit = element('button', '', 'Edit'); edit.type = 'button'; edit.dataset.editComment = comment.commentId;
94
+ edit.addEventListener('click', () => actions.after(form('Edit your comment', comment.body ?? '',
95
+ (body) => write('comment.edit', { articleId, commentId: comment.commentId, body }))));
96
+ const remove = element('button', '', 'Delete'); remove.type = 'button'; remove.dataset.deleteComment = comment.commentId;
97
+ remove.addEventListener('click', () => write('comment.delete', { articleId, commentId: comment.commentId }));
98
+ actions.append(edit, remove);
99
+ } else {
100
+ const report = element('button', '', 'Report'); report.type = 'button'; report.dataset.reportComment = comment.commentId;
101
+ report.addEventListener('click', () => write('comment.report', { articleId, commentId: comment.commentId, reason: 'OTHER' }));
102
+ actions.append(report);
103
+ }
104
+ item.append(actions);
105
+ }
106
+ if (comment.replies.length) {
107
+ const replies = element('ol', 'gala-comment-replies');
108
+ replies.append(...comment.replies.map(commentNode)); item.append(replies);
109
+ }
110
+ return item;
111
+ }
240
112
 
241
- ${loading
242
- ? html`<p class="gala-comments__status" role="status">Loading comments…</p>`
243
- : threads.length === 0
244
- ? html`<p class="gala-comments__empty">No comments yet.</p>`
245
- : html`<ol class="gala-comments">
246
- ${threads.map((comment) => html`
247
- <${Comment} key=${comment.commentId} comment=${comment} reader=${reader}
248
- busy=${busy} onReply=${reply} onEdit=${edit} onDelete=${remove} />`)}
249
- </ol>`}
113
+ function render() {
114
+ if (statusNode) {
115
+ statusNode.textContent = state.phase === 'loading'
116
+ ? 'Loading comments…'
117
+ : state.phase === 'unavailable' ? 'Comments are temporarily unavailable.' : state.status;
118
+ }
119
+ if (state.phase !== 'ready') {
120
+ root.replaceChildren();
121
+ return;
122
+ }
123
+ const section = element('section', 'gala-comments-island'); section.setAttribute('aria-label', 'Comments');
124
+ section.append(element('p', 'gala-comments__heading', state.total === 1 ? '1 comment' : `${state.total} comments`));
125
+ if (sessionUser.value) section.append(form('Add a comment', '', (body) => write('comment.create', { articleId, body })));
126
+ else {
127
+ const prompt = element('p', 'gala-comments__prompt');
128
+ const signIn = element('button', '', 'Sign in to join the conversation'); signIn.type = 'button';
129
+ signIn.addEventListener('click', () => requestSignIn({ kind: 'comment' })); prompt.append(signIn); section.append(prompt);
130
+ }
131
+ if (!state.items.length) section.append(element('p', 'gala-comments__empty', 'No comments yet.'));
132
+ else { const list = element('ol', 'gala-comments'); list.append(...threads(state.items).map(commentNode)); section.append(list); }
133
+ if (state.cursor) {
134
+ const more = element('button', 'gala-comments__more', 'Show more comments'); more.type = 'button'; more.disabled = state.busy;
135
+ more.addEventListener('click', async () => {
136
+ state.busy = true; state.status = 'Loading more comments…'; render();
137
+ try { await load(state.cursor, { append: true }); state.status = ''; }
138
+ catch { state.status = 'More comments couldn’t be loaded. Try again.'; }
139
+ state.busy = false; render();
140
+ }); section.append(more);
141
+ }
142
+ root.replaceChildren(section);
143
+ }
250
144
 
251
- ${cursor && html`
252
- <button type="button" class="gala-comments__more" disabled=${busy}
253
- onClick=${() => { setBusy(true); load(cursor, { append: true })
254
- .catch(() => setStatus('Could not load more comments.'))
255
- .finally(() => setBusy(false)); }}>
256
- Show more comments
257
- </button>`}
258
- </section>`;
145
+ window.addEventListener('gala-session-change', render);
146
+ render();
147
+ load().then(() => { state.phase = 'ready'; })
148
+ .catch(() => { state.phase = 'unavailable'; })
149
+ .finally(render);
259
150
  }
260
151
 
261
- for (const mount of document.querySelectorAll('[data-gala-comments]')) {
262
- const endpoint = mount.closest('[data-engagement-url]')?.dataset.engagementUrl;
263
- if (endpoint) render(html`<${Comments} endpoint=${endpoint} />`, mount);
264
- }
152
+ document.querySelectorAll('[data-gala-comments]').forEach((root) => {
153
+ const endpoint = root.closest('[data-engagement-url]')?.dataset.engagementUrl;
154
+ if (endpoint) commentsController(root, endpoint);
155
+ });
@@ -9,9 +9,7 @@
9
9
  * `sessionUser` is a signal rather than a variable because more than one island depends on it and
10
10
  * they must not each keep their own copy going stale in its own way.
11
11
  */
12
- import { signal } from './vendor/signals.js';
13
-
14
- export const sessionUser = signal(null);
12
+ export const sessionUser = { value: null };
15
13
 
16
14
  const sessionFrame = document.querySelector('[data-gala-session-frame]');
17
15
  const frameOrigin = sessionFrame ? new URL(sessionFrame.src).origin : null;
@@ -39,7 +37,14 @@ export function sendEngagementWrite(operation, payload) {
39
37
  }
40
38
  const requestId = crypto.randomUUID();
41
39
  return new Promise((resolve, reject) => {
42
- pending.set(requestId, { resolve, reject });
40
+ const timeout = setTimeout(() => {
41
+ pending.delete(requestId);
42
+ reject(new Error('REQUEST_TIMEOUT'));
43
+ }, 10_000);
44
+ pending.set(requestId, {
45
+ resolve: (value) => { clearTimeout(timeout); resolve(value); },
46
+ reject: (error) => { clearTimeout(timeout); reject(error); }
47
+ });
43
48
  sessionFrame.contentWindow.postMessage(
44
49
  { type: 'gala-engagement-write', requestId, operation, payload }, frameOrigin);
45
50
  });
@@ -69,5 +74,6 @@ if (sessionFrame) {
69
74
  if (event.data?.type !== 'gala-session') return;
70
75
  const user = event.data.user;
71
76
  sessionUser.value = user && typeof user.id === 'string' ? user : null;
77
+ window.dispatchEvent(new CustomEvent('gala-session-change'));
72
78
  });
73
79
  }