@rathnasgala/theme 0.0.19 → 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.
- package/package.json +1 -1
- package/payload/.gala/managed-files.json +23 -20
- package/payload/eleventy.config.js +15 -1
- package/payload/lib/site-config.js +20 -9
- package/payload/package-lock.json +487 -2
- package/payload/package.json +7 -6
- package/payload/scripts/build-reader.js +24 -0
- package/payload/scripts/lint.js +21 -0
- package/payload/src/_data/site.js +12 -1
- package/payload/src/_includes/components/ui.njk +26 -17
- package/payload/src/_includes/layouts/base.njk +10 -11
- package/payload/src/_includes/layouts/post.njk +5 -6
- package/payload/src/accent.11ty.js +10 -0
- package/payload/src/assets/engagement-comments.js +115 -234
- package/payload/src/assets/engagement-transport.js +10 -4
- package/payload/src/assets/interactions.js +61 -1
- package/payload/src/assets/reader.js +2 -0
- package/payload/src/assets/theme.css +1 -588
- package/payload/src/client/reader.js +5 -0
- package/payload/src/styles/theme.css +811 -0
- package/payload/src/assets/vendor/hooks.js +0 -2
- package/payload/src/assets/vendor/htm.js +0 -2
- package/payload/src/assets/vendor/preact.js +0 -2
- package/payload/src/assets/vendor/signals-core.js +0 -2
- package/payload/src/assets/vendor/signals.js +0 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<html lang="{{ post.language or site.site.defaultLanguage }}" data-theme="{{ site.design.theme }}" data-layout="{{ site.design.layout
|
|
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
|
-
<
|
|
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/
|
|
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-
|
|
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-
|
|
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,
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
const byParent = new Map();
|
|
11
|
+
function threads(items) {
|
|
12
|
+
const children = new Map();
|
|
37
13
|
for (const item of items) {
|
|
38
|
-
const
|
|
39
|
-
if (!
|
|
40
|
-
|
|
14
|
+
const parent = item.parentCommentId ?? '';
|
|
15
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
16
|
+
children.get(parent).push(item);
|
|
41
17
|
}
|
|
42
|
-
const build = (
|
|
43
|
-
...
|
|
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
|
|
51
|
-
const [
|
|
52
|
-
const
|
|
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
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
173
|
-
if (!
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
262
|
-
const endpoint =
|
|
263
|
-
if (endpoint)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,31 @@ 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
|
+
}
|
|
433
|
+
/* The frame is cross-origin, so its content height is not readable from here — it reports
|
|
434
|
+
its own, and the box is sized to it. Without this the account panel scrolled inside a fixed
|
|
435
|
+
box, clipping the first line of its own text. */
|
|
436
|
+
if (event.data?.type === 'gala-session-height') {
|
|
437
|
+
const height = Number(event.data.height);
|
|
438
|
+
if (Number.isFinite(height) && height > 0 && height < 2000) {
|
|
439
|
+
sessionFrame.style.height = `${Math.ceil(height)}px`;
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
395
443
|
if (event.data?.type !== 'gala-session') return;
|
|
396
444
|
sessionUser = event.data.user && typeof event.data.user.id === 'string' ? event.data.user : null;
|
|
397
445
|
const control = document.querySelector('[data-user-control]');
|
|
@@ -400,12 +448,24 @@ if (sessionFrame) {
|
|
|
400
448
|
control.setAttribute('aria-label', displayName
|
|
401
449
|
? `Account: ${displayName}` : 'Sign in or view account');
|
|
402
450
|
control.title = displayName ? `Account: ${displayName}` : 'Account';
|
|
451
|
+
const label = control.querySelector('[data-user-label]');
|
|
452
|
+
if (label) label.textContent = displayName || 'Sign in';
|
|
403
453
|
}
|
|
404
454
|
// The session frame reports on every page load, signed in or not. Re-reading engagement
|
|
405
455
|
// then duplicated the request made on load and returned an identical payload for anyone
|
|
406
456
|
// who was not signed in. Only a change of reader can change what the API answers.
|
|
407
457
|
const changed = renderedSessionUser !== (sessionUser?.id ?? null);
|
|
408
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
|
+
}
|
|
409
469
|
document.querySelectorAll('[data-engagement-url]').forEach((region) => {
|
|
410
470
|
if (changed) refreshEngagement(region);
|
|
411
471
|
});
|