@rathnasgala/theme 0.0.20 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  <!doctype html>
2
- <html lang="{{ post.language or site.site.defaultLanguage }}" data-theme="{{ site.design.theme | default('modern') }}" data-layout="{{ site.design.layout }}" data-palette="{{ site.design.palette | default('default') }}" data-mode="{{ site.design.colorMode | default('system') }}"{% if site.accent %} style="--gala-accent-light: {{ site.accent.light }}; --gala-accent-dark: {{ site.accent.dark }}"{% endif %}>
2
+ <html lang="{{ post.language or site.site.defaultLanguage }}" data-theme="{{ site.design.theme | default('modern') }}" data-layout="{{ site.design.layout | default('article-first') }}" data-palette="{{ site.design.palette | default('default') }}" data-mode="{{ site.design.colorMode | default('system') }}">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <script>(function(){try{var m=localStorage.getItem('gala-color-mode');if(m==='light'||m==='dark'||m==='system')document.documentElement.dataset.mode=m}catch(e){}})();</script>
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self' 'sha256-{{ themeBootstrap | sha256Csp }}'{% if seo %} 'sha256-{{ seo.structuredDataJson | sha256Csp }}'{% endif %}; style-src 'self'; img-src 'self' data: https:; connect-src 'self' {{ site.platform.apiBaseUrl or 'https://api.gala67.com' }}; frame-src {{ site.platform.apiBaseUrl or 'https://api.gala67.com' }} https://www.youtube-nocookie.com https://codepen.io; form-action 'self'; upgrade-insecure-requests">
7
+ <script>{{ themeBootstrap | safe }}</script>
7
8
  <title>{{ post.frontmatter.title or title }} · {{ site.site.name }}</title>
8
9
  {% if post %}<link rel="canonical" href="{{ post.canonicalUrl }}">{% endif %}
9
10
  {% for alternate in hreflangLinks %}<link rel="alternate" hreflang="{{ alternate.hreflang }}" href="{{ alternate.href }}">{% endfor %}
@@ -23,20 +24,17 @@
23
24
  {% endif %}
24
25
  {% if post.publicationState == "tombstoned" %}<meta name="robots" content="noindex">{% endif %}
25
26
  <link rel="stylesheet" href="{{ '/assets/theme.css' | url }}">
27
+ <link rel="stylesheet" href="{{ '/assets/accent.css' | url }}">
26
28
  <link rel="stylesheet" href="{{ '/custom.css' | url }}">
27
- <script src="{{ '/assets/theme-mode.js' | url }}" defer></script>
28
- <script src="{{ '/assets/preferences.js' | url }}" defer></script>
29
29
  <link rel="icon" href="{{ '/assets/favicon.svg' | url }}" type="image/svg+xml">
30
- <script src="{{ '/assets/interactions.js' | url }}" defer></script>
31
- <script type="module" src="{{ '/assets/engagement-comments.js' | url }}"></script>
32
- <script src="{{ '/assets/search.js' | url }}" defer></script>
30
+ <script src="{{ '/assets/reader.js' | url }}" defer></script>
33
31
  </head>
34
32
  <body>
33
+ <a class="gala-skip-link" href="#main-content">Skip to content</a>
35
34
  <header class="gala-site-header">
36
- <a class="gala-icon-control" href="{{ '/' | url }}" aria-label="Home" title="Home">
37
- <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.2 12 3.5l9 6.7"/><path d="M5.4 8.9V19a1.4 1.4 0 0 0 1.4 1.4h3.1v-5.1h4.2v5.1h3.1A1.4 1.4 0 0 0 18.6 19V8.9"/></svg>
38
- </a>
35
+ <a class="gala-site-name" href="{{ '/' | url }}" aria-label="Home">{{ site.site.name }}</a>
39
36
  <nav class="gala-site-header__actions" aria-label="Site controls">
37
+ {% if post %}<a class="gala-header-link" href="#article">Article</a><a class="gala-header-link" href="#conversation">Conversation</a>{% endif %}
40
38
  {% if site.contact.enabled %}<a class="gala-icon-control" href="{{ '/contact/' | url }}" aria-label="Contact author" title="Contact">
41
39
  <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5.4" width="18" height="13.2" rx="2.2"/><path d="m3.9 7.3 7.2 5.1a1.6 1.6 0 0 0 1.8 0l7.2-5.1"/></svg>
42
40
  </a>{% endif %}
@@ -50,8 +48,9 @@
50
48
  <button class="gala-icon-control" type="button" data-open-dialog="gala-search-dialog" aria-label="Search" title="Search">
51
49
  <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20.3 20.3-4.4-4.4"/></svg>
52
50
  </button>
53
- <button class="gala-icon-control" type="button" data-open-dialog="gala-account-dialog" data-user-control aria-label="Sign in or view account" title="Account">
51
+ <button class="gala-account-control" type="button" data-open-dialog="gala-account-dialog" data-user-control aria-label="Sign in or view account" title="Account">
54
52
  <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 20.4v-1.7a3.9 3.9 0 0 0-3.9-3.9H8.9A3.9 3.9 0 0 0 5 18.7v1.7"/><circle cx="12" cy="7.6" r="3.9"/></svg>
53
+ <span data-user-label>Sign in</span>
55
54
  </button>
56
55
  </nav>
57
56
  </header>
@@ -1,12 +1,11 @@
1
1
  ---
2
2
  layout: layouts/base.njk
3
3
  ---
4
- {% from "components/ui.njk" import engagementSummary, languageSwitcher, shareControl, tableOfContents %}
5
- <article>
6
- {% if post.publicationState == "published" %}<h1>{{ post.frontmatter.title }}</h1>{% endif %}
4
+ {% from "components/ui.njk" import engagementSummary, languageSwitcher, tableOfContents %}
5
+ <article id="article" class="gala-article">
6
+ {% if post.publicationState == "published" %}<header class="gala-article-header"><p class="gala-eyebrow">Published {{ post.frontmatter.publishAfterDate }} · {{ post.language }}</p><h1>{{ post.frontmatter.title }}</h1><p class="gala-byline">By {{ post.frontmatter.author or site.site.author or site.site.name }}</p></header>{% endif %}
7
7
  {% if post.publicationState == "published" %}{{ languageSwitcher(hreflangLinks, post.language) }}{% endif %}
8
8
  {% if postTableOfContents.length %}{{ tableOfContents(postTableOfContents) }}{% endif %}
9
- {{ content | safe }}
10
- {% if post.publicationState == "published" %}{{ engagementSummary(post.id, engagement, site.platform.apiBaseUrl or "https://api.gala67.com") }}{% endif %}
11
- {% if post.publicationState == "published" %}{{ shareControl(post.canonicalUrl, shareTargets) }}{% endif %}
9
+ <div class="gala-markdown">{{ content | safe }}</div>
10
+ {% if post.publicationState == "published" %}{{ engagementSummary(post.id, post.canonicalUrl, engagement, site.platform.apiBaseUrl or "https://api.gala67.com") }}{% endif %}
12
11
  </article>
@@ -0,0 +1,10 @@
1
+ export default class AccentStylesheet {
2
+ data() {
3
+ return { permalink: '/assets/accent.css', eleventyExcludeFromCollections: true };
4
+ }
5
+
6
+ render({ site }) {
7
+ if (!site.accent) return '';
8
+ return `:root{--gala-accent-light:${site.accent.light};--gala-accent-dark:${site.accent.dark}}\n`;
9
+ }
10
+ }
@@ -1,264 +1,145 @@
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>
24
+ function commentsController(root, endpoint) {
25
+ const state = { items: [], cursor: null, total: 0, busy: false, status: '', loading: true };
26
+ const articleId = /\/v1\/articles\/([^/]+)\/engagement/.exec(endpoint)?.[1] ?? '';
79
27
 
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);
135
-
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 } = {}) => {
28
+ async function load(nextCursor = '', { append = false, fresh = false } = {}) {
140
29
  const url = new URL(endpoint);
141
30
  if (nextCursor) url.searchParams.set('commentsCursor', nextCursor);
142
31
  const response = await fetch(url, {
143
- headers: { Accept: 'application/json' },
144
- credentials: 'omit',
145
- cache: fresh ? 'no-store' : 'default',
32
+ headers: { Accept: 'application/json' }, credentials: 'omit', cache: fresh ? 'no-store' : 'default',
146
33
  });
147
34
  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]);
35
+ const page = (await response.json())?.data?.comments;
36
+ if (!page || !Array.isArray(page.items)) throw new TypeError('Comment page is invalid');
37
+ state.items = append ? [...state.items, ...page.items] : page.items;
38
+ state.cursor = page.nextCursor ?? null;
39
+ if (Number.isSafeInteger(page.totalCount)) state.total = page.totalCount;
40
+ }
171
41
 
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]);
42
+ async function write(operation, payload) {
43
+ if (!sessionUser.value) { requestSignIn({ kind: 'comment' }); return false; }
44
+ state.busy = true; state.status = ''; render();
177
45
  try {
178
46
  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
47
  await load('', { fresh: true });
182
48
  return true;
183
49
  } catch (error) {
184
- if (optimistic) {
185
- setItems((current) => current.filter((item) => item.commentId !== optimistic.commentId));
186
- }
187
- setStatus(engagementErrorMessage(error.message));
50
+ state.status = engagementErrorMessage(error.message);
188
51
  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]);
52
+ } finally { state.busy = false; render(); }
53
+ }
198
54
 
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,
55
+ function form(label, initial, submit) {
56
+ const formNode = element('form', 'gala-comment__form');
57
+ const field = element('textarea');
58
+ field.rows = 3; field.value = initial; field.placeholder = label; field.setAttribute('aria-label', label);
59
+ const send = element('button', '', 'Post'); send.type = 'submit'; send.disabled = state.busy;
60
+ formNode.append(field, send);
61
+ formNode.addEventListener('submit', async (event) => {
62
+ event.preventDefault();
63
+ const body = field.value.trim();
64
+ if (body && await submit(body)) field.value = '';
211
65
  });
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>`}
66
+ return formNode;
67
+ }
238
68
 
239
- ${status && html`<p class="gala-comments__status" role="status">${status}</p>`}
69
+ function commentNode(comment) {
70
+ const item = element('li', 'gala-comment');
71
+ item.dataset.commentId = comment.commentId; item.dataset.depth = comment.depth;
72
+ const meta = element('p', 'gala-comment__meta');
73
+ meta.append(element('strong', '', comment.author?.displayName ?? '[deleted]'));
74
+ if (comment.createdAt) {
75
+ const time = element('time', '', new Date(comment.createdAt).toLocaleDateString());
76
+ time.dateTime = comment.createdAt; meta.append(time);
77
+ }
78
+ item.append(meta, element('p', 'gala-comment__body', comment.deleted ? '[deleted]' : comment.body));
79
+ if (!comment.deleted) {
80
+ const actions = element('div', 'gala-comment-actions');
81
+ if (comment.depth < MAXIMUM_DEPTH) {
82
+ const reply = element('button', '', 'Reply'); reply.type = 'button'; reply.dataset.replyComment = comment.commentId;
83
+ reply.addEventListener('click', () => {
84
+ const existing = item.querySelector(':scope > .gala-comment__form');
85
+ if (existing) { existing.remove(); return; }
86
+ actions.after(form(`Reply to ${comment.author?.displayName ?? 'this comment'}`, '',
87
+ (body) => write('comment.create', { articleId, parentCommentId: comment.commentId, body })));
88
+ }); actions.append(reply);
89
+ }
90
+ const mine = sessionUser.value && comment.author?.userId === sessionUser.value.id;
91
+ if (mine) {
92
+ const edit = element('button', '', 'Edit'); edit.type = 'button'; edit.dataset.editComment = comment.commentId;
93
+ edit.addEventListener('click', () => actions.after(form('Edit your comment', comment.body ?? '',
94
+ (body) => write('comment.edit', { articleId, commentId: comment.commentId, body }))));
95
+ const remove = element('button', '', 'Delete'); remove.type = 'button'; remove.dataset.deleteComment = comment.commentId;
96
+ remove.addEventListener('click', () => write('comment.delete', { articleId, commentId: comment.commentId }));
97
+ actions.append(edit, remove);
98
+ } else {
99
+ const report = element('button', '', 'Report'); report.type = 'button'; report.dataset.reportComment = comment.commentId;
100
+ report.addEventListener('click', () => write('comment.report', { articleId, commentId: comment.commentId, reason: 'OTHER' }));
101
+ actions.append(report);
102
+ }
103
+ item.append(actions);
104
+ }
105
+ if (comment.replies.length) {
106
+ const replies = element('ol', 'gala-comment-replies');
107
+ replies.append(...comment.replies.map(commentNode)); item.append(replies);
108
+ }
109
+ return item;
110
+ }
240
111
 
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>`}
112
+ function render() {
113
+ const section = element('section', 'gala-comments-island'); section.setAttribute('aria-label', 'Comments');
114
+ section.append(element('h2', 'gala-comments__heading', state.total === 1 ? '1 comment' : `${state.total} comments`));
115
+ if (sessionUser.value) section.append(form('Add a comment', '', (body) => write('comment.create', { articleId, body })));
116
+ else {
117
+ const prompt = element('p', 'gala-comments__prompt');
118
+ const signIn = element('button', '', 'Sign in to join the conversation'); signIn.type = 'button';
119
+ signIn.addEventListener('click', () => requestSignIn({ kind: 'comment' })); prompt.append(signIn); section.append(prompt);
120
+ }
121
+ if (state.status) { const status = element('p', 'gala-comments__status', state.status); status.role = 'status'; section.append(status); }
122
+ if (state.loading) section.append(element('p', 'gala-comments__status', 'Loading comments…'));
123
+ else if (!state.items.length) section.append(element('p', 'gala-comments__empty', 'No comments yet.'));
124
+ else { const list = element('ol', 'gala-comments'); list.append(...threads(state.items).map(commentNode)); section.append(list); }
125
+ if (state.cursor) {
126
+ const more = element('button', 'gala-comments__more', 'Show more comments'); more.type = 'button'; more.disabled = state.busy;
127
+ more.addEventListener('click', async () => {
128
+ state.busy = true; render();
129
+ try { await load(state.cursor, { append: true }); } catch { state.status = 'Could not load more comments.'; }
130
+ state.busy = false; render();
131
+ }); section.append(more);
132
+ }
133
+ root.replaceChildren(section);
134
+ }
250
135
 
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>`;
136
+ window.addEventListener('gala-session-change', render);
137
+ render();
138
+ load().catch(() => { state.status = 'Comments are temporarily unavailable.'; })
139
+ .finally(() => { state.loading = false; render(); });
259
140
  }
260
141
 
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
- }
142
+ document.querySelectorAll('[data-gala-comments]').forEach((root) => {
143
+ const endpoint = root.closest('[data-engagement-url]')?.dataset.engagementUrl;
144
+ if (endpoint) commentsController(root, endpoint);
145
+ });
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  function selectableFallback(control) {
2
2
  const region = control.closest('.gala-share');
3
3
  const fallback = region?.querySelector('.gala-share__fallback');
4
+ fallback?.classList.add('gala-share__fallback--visible');
4
5
  fallback?.focus();
5
6
  fallback?.select();
6
7
  const status = region?.querySelector('.gala-share__status');
@@ -87,6 +88,20 @@ document.addEventListener('click', async (event) => {
87
88
  return;
88
89
  }
89
90
 
91
+ const nativeShare = event.target.closest('[data-native-share]');
92
+ if (nativeShare) {
93
+ if (navigator.share) {
94
+ try {
95
+ await navigator.share({ title: document.title, url: nativeShare.dataset.nativeShare });
96
+ } catch (error) {
97
+ if (error.name !== 'AbortError') selectableFallback(nativeShare);
98
+ }
99
+ } else {
100
+ selectableFallback(nativeShare);
101
+ }
102
+ return;
103
+ }
104
+
90
105
  const copy = event.target.closest('[data-copy-code]');
91
106
  if (!copy) return;
92
107
  const code = copy.closest('.gala-code-block')?.querySelector('code');
@@ -225,6 +240,7 @@ let sessionUser = null;
225
240
  // The reader the current engagement render was built for: null until the frame reports, and
226
241
  // null again for a signed-out reader, so an anonymous load never re-requests.
227
242
  let renderedSessionUser = null;
243
+ let readerStateRequestId = null;
228
244
  const pendingEngagementWrites = new Map();
229
245
 
230
246
  /**
@@ -331,7 +347,14 @@ function sendEngagementWrite(operation, payload) {
331
347
  if (!sessionFrame || !sessionUser) return Promise.reject(new Error('AUTHENTICATION_REQUIRED'));
332
348
  const requestId = crypto.randomUUID();
333
349
  return new Promise((resolve, reject) => {
334
- pendingEngagementWrites.set(requestId, { resolve, reject });
350
+ const timeout = setTimeout(() => {
351
+ pendingEngagementWrites.delete(requestId);
352
+ reject(new Error('REQUEST_TIMEOUT'));
353
+ }, 10_000);
354
+ pendingEngagementWrites.set(requestId, {
355
+ resolve: (value) => { clearTimeout(timeout); resolve(value); },
356
+ reject: (error) => { clearTimeout(timeout); reject(error); }
357
+ });
335
358
  sessionFrame.contentWindow.postMessage({
336
359
  type: 'gala-engagement-write', requestId, operation, payload
337
360
  }, new URL(sessionFrame.src).origin);
@@ -392,6 +415,21 @@ if (sessionFrame) {
392
415
  else pending.reject(new Error(event.data.error?.code || 'ENGAGEMENT_WRITE_FAILED'));
393
416
  return;
394
417
  }
418
+ if (event.data?.type === 'gala-reader-state-result') {
419
+ if (event.data.requestId !== readerStateRequestId || event.data.ok !== true) return;
420
+ readerStateRequestId = null;
421
+ const active = new Set(Array.isArray(event.data.state?.reactions) ? event.data.state.reactions : []);
422
+ document.querySelectorAll('[data-reaction]').forEach((control) => {
423
+ control.setAttribute('aria-pressed', String(active.has(control.dataset.reaction)));
424
+ });
425
+ document.querySelectorAll('[data-follow-article]').forEach((control) => {
426
+ const following = event.data.state?.following === true;
427
+ control.setAttribute('aria-pressed', String(following));
428
+ control.setAttribute('aria-label', following ? 'Unfollow article' : 'Follow article');
429
+ control.title = following ? 'Unfollow article' : 'Follow article';
430
+ });
431
+ return;
432
+ }
395
433
  /* The frame is cross-origin, so its content height is not readable from here — it reports
396
434
  its own, and the box is sized to it. Without this the account panel scrolled inside a fixed
397
435
  box, clipping the first line of its own text. */
@@ -410,12 +448,24 @@ if (sessionFrame) {
410
448
  control.setAttribute('aria-label', displayName
411
449
  ? `Account: ${displayName}` : 'Sign in or view account');
412
450
  control.title = displayName ? `Account: ${displayName}` : 'Account';
451
+ const label = control.querySelector('[data-user-label]');
452
+ if (label) label.textContent = displayName || 'Sign in';
413
453
  }
414
454
  // The session frame reports on every page load, signed in or not. Re-reading engagement
415
455
  // then duplicated the request made on load and returned an identical payload for anyone
416
456
  // who was not signed in. Only a change of reader can change what the API answers.
417
457
  const changed = renderedSessionUser !== (sessionUser?.id ?? null);
418
458
  renderedSessionUser = sessionUser?.id ?? null;
459
+ if (sessionUser) {
460
+ const region = document.querySelector('[data-article-id]');
461
+ if (region) {
462
+ readerStateRequestId = crypto.randomUUID();
463
+ sessionFrame.contentWindow.postMessage({
464
+ type: 'gala-reader-state-request', requestId: readerStateRequestId,
465
+ articleId: region.dataset.articleId
466
+ }, sessionOrigin);
467
+ }
468
+ }
419
469
  document.querySelectorAll('[data-engagement-url]').forEach((region) => {
420
470
  if (changed) refreshEngagement(region);
421
471
  });