@rathnasgala/theme 0.0.16 → 0.0.18
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 +15 -8
- package/payload/lib/performance-budget.js +10 -1
- package/payload/lib/site-config.js +23 -3
- package/payload/src/_includes/components/ui.njk +5 -6
- package/payload/src/_includes/layouts/base.njk +8 -7
- package/payload/src/assets/engagement-comments.js +264 -0
- package/payload/src/assets/engagement-transport.js +73 -0
- package/payload/src/assets/interactions.js +12 -196
- package/payload/src/assets/theme-mode.js +14 -2
- package/payload/src/assets/theme.css +322 -47
- package/payload/src/assets/vendor/hooks.js +2 -0
- package/payload/src/assets/vendor/htm.js +2 -0
- package/payload/src/assets/vendor/preact.js +2 -0
- package/payload/src/assets/vendor/signals-core.js +2 -0
- package/payload/src/assets/vendor/signals.js +2 -0
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"themePackage": {
|
|
4
4
|
"name": "@rathnasgala/theme",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.18",
|
|
6
6
|
"availableDesignThemes": [
|
|
7
7
|
"editorial"
|
|
8
8
|
],
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
"eleventy.config.js": "efe587447e3dc374a220c7fa037fdf2fffc02d09ba43a4d2b96d6c6aae0e3325",
|
|
15
15
|
"lib/build-manifest.js": "88949f0112d683ca809bb67ca969e15634792423559a67bc79a534cd807e5535",
|
|
16
16
|
"lib/engagement-snapshot.js": "298c11b1ab135a3a98a4e31b6320ab60dfd7fda6a9d9053df37d86282a5a6687",
|
|
17
|
-
"lib/performance-budget.js": "
|
|
17
|
+
"lib/performance-budget.js": "c279622dfde237cd6e3c64775c91d770a0e1f54f5f94257ccc95d9a44de2c4c9",
|
|
18
18
|
"lib/provider-fixtures/embeds.v1.json": "5f26909fb433135321ddf7fdd14e45aeb702805c88f5676941166c5735143ee2",
|
|
19
19
|
"lib/provider-fixtures/share-intents.v1.json": "66a8eedfcff711253ee45ceb94b53f7d5a6be3ddbbb3d1f0d4e31a114326cc0a",
|
|
20
20
|
"lib/publication-state.js": "1b59c29a18bbb1193bddea898dac3a4fb832c7bc4751263be086f3d64392dea0",
|
|
21
21
|
"lib/render-markdown.js": "40e6d6743b76de6c16d138475f188a4263967530c370d8b26a363f4df58c2caf",
|
|
22
22
|
"lib/seo.js": "107de8804931945b8e73894877fc394d6b535a9d0d969a4ba5b68f2e136e0a17",
|
|
23
23
|
"lib/share-targets.js": "458aff86fab2b62a364442cd8c612565c6e0b53eef2f20cb6237cc562b1e0731",
|
|
24
|
-
"lib/site-config.js": "
|
|
24
|
+
"lib/site-config.js": "da7d2dffab62c7346c9930494b85fc6ea735d4f50565bbd0454a04393f8cad96",
|
|
25
25
|
"package-lock.json": "c97a6a2f794aef083a39a8d2628eccc4a3c484e1c6225899b77ec1fb7c466d9e",
|
|
26
26
|
"package.json": "4c60bea06f39aec755ac68caf8d8841c1d59d0f6121fad7f4396231ce14712dc",
|
|
27
27
|
"src/404.njk": "be0d44370ac5402156aaf3d32ea581057bc61ec94c1a5f92bb809733af7a8a20",
|
|
@@ -30,18 +30,25 @@
|
|
|
30
30
|
"src/_data/feedLinks.js": "06c93352ebd888acb431a1c9a8be6ecff757895468e9f2d336761038751c2d5e",
|
|
31
31
|
"src/_data/languages.js": "1358748f7fa2f74406d3295a030827d6229a53e94da72cf712e719d7a356aeac",
|
|
32
32
|
"src/_data/site.js": "d9c001f4cc12a34027dd92303530b11b5402d47613459bb997e22c4bcbea326d",
|
|
33
|
-
"src/_includes/components/ui.njk": "
|
|
34
|
-
"src/_includes/layouts/base.njk": "
|
|
33
|
+
"src/_includes/components/ui.njk": "23eb647d8658868449ec6bb809e965a85302124a97639f94d5d28af72df56e4c",
|
|
34
|
+
"src/_includes/layouts/base.njk": "6914da799eb31fea3fc230ab196252129ccca90f27ccb08bbcee81741a91ced4",
|
|
35
35
|
"src/_includes/layouts/post.njk": "1accd2832064ad19ee7bf18daf5fa1e60569f32c9b7a547934a6361f747d7eb7",
|
|
36
36
|
"src/assets/embed-codepen.svg": "e9def697f38c466fb2b6652535c4c814e8cbbbefc6e052a69f335aeb27e8356b",
|
|
37
37
|
"src/assets/embed-gist.svg": "fcd9bc2bf29a5c118492b853c0a01390b7539c38ddb985da3c2a6e2f246a39f7",
|
|
38
38
|
"src/assets/embed-x.svg": "0ae74edc6790ec7e933067fe5392bce756e93fa7ad71caacf1c1ca99fa4ec9b6",
|
|
39
39
|
"src/assets/embed-youtube.svg": "10d192f2b14b67664fea88c8cf246321d20190def51d59fad5762e552219b067",
|
|
40
|
-
"src/assets/
|
|
40
|
+
"src/assets/engagement-comments.js": "b20d9268c9d908b4453a42188eaca25349f25534fb04a9293c9e688a982cd8f8",
|
|
41
|
+
"src/assets/engagement-transport.js": "4f9ca0cd669bf8abc25f2909104e3e4b231657ef97d36a17d06e53454d711786",
|
|
42
|
+
"src/assets/interactions.js": "dd4e343505e528d89596ca67db679b52c989e022f93399193e4377db01fc8a08",
|
|
41
43
|
"src/assets/preferences.js": "789127bee013ed6a1bbf8cefd7b9109291c16220e7a6e09314e3c93c54731d1d",
|
|
42
44
|
"src/assets/search.js": "df425f0239277792ad39f2138bc89cb66cc4ecda5ed59042d46abc2f05372088",
|
|
43
|
-
"src/assets/theme-mode.js": "
|
|
44
|
-
"src/assets/theme.css": "
|
|
45
|
+
"src/assets/theme-mode.js": "944b29db11a577961a60ddcfa52f07ef2411f3f3631bea85bd79be16428c08be",
|
|
46
|
+
"src/assets/theme.css": "2a2367f2c875121cc95bc789a0d5889960813e660ca7722b328b39b4ff5068a8",
|
|
47
|
+
"src/assets/vendor/hooks.js": "ae6ec58592683ca64fa4d65b20d40b103df62a673bcc4712515cf165950b07f3",
|
|
48
|
+
"src/assets/vendor/htm.js": "e4dac9bc40a6f2ec590413d787ae30bbcfdfcde4f28971ee65d9578271b7aad4",
|
|
49
|
+
"src/assets/vendor/preact.js": "712517dd2a42c3c22b5ab0264ad7e9a2b1e8be6cf97fa00dbedd98203617e616",
|
|
50
|
+
"src/assets/vendor/signals-core.js": "1e10560fec5118c9e35eb312c869fe51f4870424766ef2ec0f7cd069bd898c8b",
|
|
51
|
+
"src/assets/vendor/signals.js": "717a88151bacf4f8b79b528a96d71be20a133fb3345230c1146929903cecfd19",
|
|
45
52
|
"src/cname.11ty.js": "206fc224df91c569e18312158e7f69803061f2952513f501ada54bd06c4f8006",
|
|
46
53
|
"src/contact.njk": "f4d74d038781e15af0acef571901c91e7753a2e21b6bef617429585698839a34",
|
|
47
54
|
"src/feed.11ty.js": "009bd2384e08e02a9405683bab1653bab3aeaec16accb0fd01ba69b24c777da9",
|
|
@@ -24,7 +24,16 @@ function assertWithin(actual, maximum, label) {
|
|
|
24
24
|
export async function enforcePerformanceBudgets({ outputDirectory, budgets }) {
|
|
25
25
|
const output = path.resolve(outputDirectory);
|
|
26
26
|
const files = await filesBelow(output);
|
|
27
|
-
|
|
27
|
+
/*
|
|
28
|
+
* Everything under `assets`, nested included. This used to compare `path.dirname` against the
|
|
29
|
+
* assets directory exactly, so anything in a subdirectory was invisible to the budget — the
|
|
30
|
+
* moment the reader runtime moved into `assets/vendor` it would have stopped being counted, and
|
|
31
|
+
* a budget that quietly ignores two thirds of the JavaScript it is meant to guard is worse than
|
|
32
|
+
* having none.
|
|
33
|
+
*/
|
|
34
|
+
const assets = path.join(output, 'assets');
|
|
35
|
+
const managedAssets = files.filter(
|
|
36
|
+
(file) => file === assets || file.startsWith(assets + path.sep));
|
|
28
37
|
const javascriptBytes = await totalBytes(managedAssets.filter((file) => file.endsWith('.js')));
|
|
29
38
|
const cssBytes = await totalBytes(managedAssets.filter((file) => file.endsWith('.css')));
|
|
30
39
|
assertWithin(javascriptBytes, budgets.managedJavaScriptBytes, 'Managed JavaScript');
|
|
@@ -7,19 +7,39 @@ const BUDGET_KEYS = Object.freeze([
|
|
|
7
7
|
'managedCssBytes',
|
|
8
8
|
'ordinaryHtmlBytes'
|
|
9
9
|
]);
|
|
10
|
+
/*
|
|
11
|
+
* Only what the theme actually implements. A value here is a promise that choosing it changes how
|
|
12
|
+
* a publication looks; anything the CSS does not answer to has no business being offered.
|
|
13
|
+
*/
|
|
10
14
|
const DESIGN_VALUES = Object.freeze({
|
|
11
15
|
layout: Object.freeze(['article-first', 'portfolio']),
|
|
12
|
-
palette: Object.freeze(['default', 'ocean'])
|
|
16
|
+
palette: Object.freeze(['default', 'ocean']),
|
|
17
|
+
typography: Object.freeze(['system', 'editorial', 'humanist', 'mono']),
|
|
18
|
+
density: Object.freeze(['compact', 'comfortable', 'spacious']),
|
|
19
|
+
spacing: Object.freeze(['compact', 'comfortable', 'spacious']),
|
|
20
|
+
radius: Object.freeze(['sharp', 'soft', 'round']),
|
|
21
|
+
motion: Object.freeze(['none', 'subtle', 'expressive']),
|
|
22
|
+
componentStyle: Object.freeze(['quiet', 'outlined', 'raised'])
|
|
13
23
|
});
|
|
14
24
|
const CONTACT_KEYS = Object.freeze([
|
|
15
25
|
'enabled', 'websiteEnabled', 'phoneEnabled'
|
|
16
26
|
]);
|
|
17
27
|
|
|
28
|
+
/*
|
|
29
|
+
* `layout` and `palette` have always been required. The rest were added later and are optional:
|
|
30
|
+
* a publication written before they existed is still a valid publication, and the theme falls
|
|
31
|
+
* back to its own defaults for anything absent. Present-but-unknown is still an error, because
|
|
32
|
+
* that is a writer expecting something the CSS will not do.
|
|
33
|
+
*/
|
|
34
|
+
const REQUIRED_DESIGN = Object.freeze(['layout', 'palette']);
|
|
35
|
+
|
|
18
36
|
function validateImplementedDesign(config) {
|
|
19
37
|
if (config?.design == null) return;
|
|
20
38
|
for (const [field, allowed] of Object.entries(DESIGN_VALUES)) {
|
|
21
|
-
|
|
22
|
-
|
|
39
|
+
const value = config.design[field];
|
|
40
|
+
if (value === undefined && !REQUIRED_DESIGN.includes(field)) continue;
|
|
41
|
+
if (!allowed.includes(value)) {
|
|
42
|
+
throw new TypeError(`Unsupported design.${field}: ${value}`);
|
|
23
43
|
}
|
|
24
44
|
}
|
|
25
45
|
}
|
|
@@ -33,13 +33,12 @@
|
|
|
33
33
|
] %}<button type="button" data-reaction="{{ reaction.value }}" aria-pressed="false">{{ reaction.label }}</button>{% endfor %}
|
|
34
34
|
</div>
|
|
35
35
|
<button type="button" data-follow-article aria-pressed="false">Follow article</button>
|
|
36
|
-
<form data-comment-create>
|
|
37
|
-
<label>Comment<textarea name="body" maxlength="5000" required></textarea></label>
|
|
38
|
-
<p data-comment-reply-context hidden></p>
|
|
39
|
-
<button type="submit">Post comment</button>
|
|
40
|
-
<button type="button" data-cancel-reply hidden>Cancel reply</button>
|
|
41
|
-
</form>
|
|
42
36
|
</section>
|
|
37
|
+
{# The conversation is an island: it owns its own state, because a thread five deep with a page
|
|
38
|
+
cursor, an open reply form and an edit in progress is state, and hand-written DOM kept losing
|
|
39
|
+
track of it. Nothing here is needed by a search engine — comments have always arrived from the
|
|
40
|
+
API after load — so rendering them on the client costs nothing the article cares about. #}
|
|
41
|
+
<div data-gala-comments></div>
|
|
43
42
|
<output data-engagement-status aria-live="polite"></output>
|
|
44
43
|
</section>
|
|
45
44
|
{% endmacro %}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<html lang="{{ post.language or site.site.defaultLanguage }}" data-theme="{{ site.design.theme }}" data-layout="{{ site.design.layout }}" data-palette="{{ site.design.palette }}" data-mode="system">
|
|
2
|
+
<html lang="{{ post.language or site.site.defaultLanguage }}" data-theme="{{ site.design.theme }}" data-layout="{{ site.design.layout }}" data-palette="{{ site.design.palette }}" data-typography="{{ site.design.typography }}" data-density="{{ site.design.density }}" data-spacing="{{ site.design.spacing }}" data-radius="{{ site.design.radius }}" data-motion="{{ site.design.motion }}" data-component-style="{{ site.design.componentStyle }}" 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">
|
|
@@ -27,29 +27,30 @@
|
|
|
27
27
|
<script src="{{ '/assets/theme-mode.js' | url }}" defer></script>
|
|
28
28
|
<script src="{{ '/assets/preferences.js' | url }}" defer></script>
|
|
29
29
|
<script src="{{ '/assets/interactions.js' | url }}" defer></script>
|
|
30
|
+
<script type="module" src="{{ '/assets/engagement-comments.js' | url }}"></script>
|
|
30
31
|
<script src="{{ '/assets/search.js' | url }}" defer></script>
|
|
31
32
|
</head>
|
|
32
33
|
<body>
|
|
33
34
|
<header class="gala-site-header">
|
|
34
35
|
<a class="gala-icon-control" href="{{ '/' | url }}" aria-label="Home" title="Home">
|
|
35
|
-
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3
|
|
36
|
+
<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>
|
|
36
37
|
</a>
|
|
37
38
|
<nav class="gala-site-header__actions" aria-label="Site controls">
|
|
38
39
|
{% if site.contact.enabled %}<a class="gala-icon-control" href="{{ '/contact/' | url }}" aria-label="Contact author" title="Contact">
|
|
39
|
-
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="
|
|
40
|
+
<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>
|
|
40
41
|
</a>{% endif %}
|
|
41
42
|
<button class="gala-icon-control" type="button" data-theme-mode-toggle aria-label="Color mode: system. Activate for light." title="Appearance">
|
|
42
|
-
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="
|
|
43
|
+
<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="M20.1 13.6A8.2 8.2 0 1 1 10.4 3.9a6.4 6.4 0 0 0 9.7 9.7Z"/></svg>
|
|
43
44
|
<span class="gala-visually-hidden" data-theme-mode-label>Theme: system</span>
|
|
44
45
|
</button>
|
|
45
46
|
<button class="gala-icon-control" type="button" data-open-dialog="gala-settings-dialog" aria-label="Settings" title="Settings">
|
|
46
|
-
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="
|
|
47
|
+
<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="12" cy="12" r="2.9"/><path d="M19.3 14.6a1.5 1.5 0 0 0 .3 1.7l.1.1a1.8 1.8 0 1 1-2.6 2.6l-.1-.1a1.5 1.5 0 0 0-2.5 1.1v.3a1.8 1.8 0 0 1-3.6 0v-.2a1.5 1.5 0 0 0-2.6-1.1l-.1.1a1.8 1.8 0 1 1-2.6-2.6l.1-.1a1.5 1.5 0 0 0-1.1-2.5h-.3a1.8 1.8 0 0 1 0-3.6h.2a1.5 1.5 0 0 0 1.1-2.6l-.1-.1a1.8 1.8 0 1 1 2.6-2.6l.1.1a1.5 1.5 0 0 0 1.7.3h.1a1.5 1.5 0 0 0 .9-1.4v-.3a1.8 1.8 0 0 1 3.6 0v.2a1.5 1.5 0 0 0 2.5 1.1l.1-.1a1.8 1.8 0 1 1 2.6 2.6l-.1.1a1.5 1.5 0 0 0-.3 1.7v.1a1.5 1.5 0 0 0 1.4.9h.3a1.8 1.8 0 0 1 0 3.6h-.2a1.5 1.5 0 0 0-1.4.9Z"/></svg>
|
|
47
48
|
</button>
|
|
48
49
|
<button class="gala-icon-control" type="button" data-open-dialog="gala-search-dialog" aria-label="Search" title="Search">
|
|
49
|
-
<svg aria-hidden="true" viewBox="0 0 24 24"
|
|
50
|
+
<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>
|
|
50
51
|
</button>
|
|
51
52
|
<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">
|
|
52
|
-
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="
|
|
53
|
+
<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
54
|
</button>
|
|
54
55
|
</nav>
|
|
55
56
|
</header>
|
|
@@ -0,0 +1,264 @@
|
|
|
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';
|
|
23
|
+
|
|
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
|
+
const MAXIMUM_DEPTH = 5;
|
|
28
|
+
|
|
29
|
+
const isoTime = (value) => {
|
|
30
|
+
const at = Date.parse(value);
|
|
31
|
+
return Number.isFinite(at) ? new Date(at).toLocaleDateString() : '';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Roots in order, each followed by its own descendants, from one flat API page. */
|
|
35
|
+
function toThreads(items) {
|
|
36
|
+
const byParent = new Map();
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
const key = item.parentCommentId ?? '';
|
|
39
|
+
if (!byParent.has(key)) byParent.set(key, []);
|
|
40
|
+
byParent.get(key).push(item);
|
|
41
|
+
}
|
|
42
|
+
const build = (parentId, depth) => (byParent.get(parentId) ?? []).map((comment) => ({
|
|
43
|
+
...comment,
|
|
44
|
+
depth,
|
|
45
|
+
replies: build(comment.commentId, depth + 1),
|
|
46
|
+
}));
|
|
47
|
+
return build('', 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
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);
|
|
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 } = {}) => {
|
|
140
|
+
const url = new URL(endpoint);
|
|
141
|
+
if (nextCursor) url.searchParams.set('commentsCursor', nextCursor);
|
|
142
|
+
const response = await fetch(url, {
|
|
143
|
+
headers: { Accept: 'application/json' },
|
|
144
|
+
credentials: 'omit',
|
|
145
|
+
cache: fresh ? 'no-store' : 'default',
|
|
146
|
+
});
|
|
147
|
+
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]);
|
|
171
|
+
|
|
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]);
|
|
177
|
+
try {
|
|
178
|
+
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
|
+
await load('', { fresh: true });
|
|
182
|
+
return true;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (optimistic) {
|
|
185
|
+
setItems((current) => current.filter((item) => item.commentId !== optimistic.commentId));
|
|
186
|
+
}
|
|
187
|
+
setStatus(engagementErrorMessage(error.message));
|
|
188
|
+
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]);
|
|
198
|
+
|
|
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,
|
|
211
|
+
});
|
|
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>`}
|
|
238
|
+
|
|
239
|
+
${status && html`<p class="gala-comments__status" role="status">${status}</p>`}
|
|
240
|
+
|
|
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>`}
|
|
250
|
+
|
|
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>`;
|
|
259
|
+
}
|
|
260
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reader's identity, and the only way to write on their behalf.
|
|
3
|
+
*
|
|
4
|
+
* Both belong to the account frame, which is served by the API and runs on its origin. The
|
|
5
|
+
* reader's token lives there and nowhere else — this page never sees it, and never should: the
|
|
6
|
+
* page is the writer's own site, and a reader's credentials are not the writer's to hold. So a
|
|
7
|
+
* write is a request posted into the frame, and the frame answers with a result.
|
|
8
|
+
*
|
|
9
|
+
* `sessionUser` is a signal rather than a variable because more than one island depends on it and
|
|
10
|
+
* they must not each keep their own copy going stale in its own way.
|
|
11
|
+
*/
|
|
12
|
+
import { signal } from './vendor/signals.js';
|
|
13
|
+
|
|
14
|
+
export const sessionUser = signal(null);
|
|
15
|
+
|
|
16
|
+
const sessionFrame = document.querySelector('[data-gala-session-frame]');
|
|
17
|
+
const frameOrigin = sessionFrame ? new URL(sessionFrame.src).origin : null;
|
|
18
|
+
const pending = new Map();
|
|
19
|
+
|
|
20
|
+
/** Readable text for the codes the API actually returns. */
|
|
21
|
+
export function engagementErrorMessage(code) {
|
|
22
|
+
if (code === 'AUTHENTICATION_REQUIRED' || code === 'INVALID_BEARER_TOKEN'
|
|
23
|
+
|| code === 'REAUTHENTICATION_REQUIRED') return 'Sign in again to continue.';
|
|
24
|
+
if (code === 'ENGAGEMENT_RATE_LIMITED') return 'You are posting too quickly; try again shortly.';
|
|
25
|
+
if (code === 'CONTACT_RATE_LIMITED') return 'You are sending too quickly; try again later.';
|
|
26
|
+
if (code === 'RESOURCE_NOT_FOUND') return 'That item is no longer available.';
|
|
27
|
+
if (code === 'INVALID_ENGAGEMENT_WRITE' || code === 'INVALID_CONTACT_SUBMISSION'
|
|
28
|
+
|| code === 'INVALID_REQUEST') return 'Check your entry and try again.';
|
|
29
|
+
if (code === 'ACCESS_DENIED') return 'That action is not available for this account.';
|
|
30
|
+
if (code === 'IDEMPOTENCY_CONFLICT' || code === 'ENGAGEMENT_STATE_CONFLICT') {
|
|
31
|
+
return 'The item changed; reload and try again.';
|
|
32
|
+
}
|
|
33
|
+
return 'The action could not be completed. Try again.';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function sendEngagementWrite(operation, payload) {
|
|
37
|
+
if (!sessionFrame || !sessionUser.value) {
|
|
38
|
+
return Promise.reject(new Error('AUTHENTICATION_REQUIRED'));
|
|
39
|
+
}
|
|
40
|
+
const requestId = crypto.randomUUID();
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
pending.set(requestId, { resolve, reject });
|
|
43
|
+
sessionFrame.contentWindow.postMessage(
|
|
44
|
+
{ type: 'gala-engagement-write', requestId, operation, payload }, frameOrigin);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Asks the page to start a sign-in and to put the reader back where they were afterwards.
|
|
50
|
+
*
|
|
51
|
+
* Dispatched as an event rather than called directly: opening the window and replaying the
|
|
52
|
+
* interrupted intent belongs to the page, and an island should not have to know how that works.
|
|
53
|
+
*/
|
|
54
|
+
export function requestSignIn(intent) {
|
|
55
|
+
document.dispatchEvent(new CustomEvent('gala-request-sign-in', { detail: intent }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (sessionFrame) {
|
|
59
|
+
window.addEventListener('message', (event) => {
|
|
60
|
+
if (event.origin !== frameOrigin || event.source !== sessionFrame.contentWindow) return;
|
|
61
|
+
if (event.data?.type === 'gala-engagement-result') {
|
|
62
|
+
const waiting = pending.get(event.data.requestId);
|
|
63
|
+
if (!waiting) return;
|
|
64
|
+
pending.delete(event.data.requestId);
|
|
65
|
+
if (event.data.ok === true) waiting.resolve(event.data.result);
|
|
66
|
+
else waiting.reject(new Error(event.data.error?.code || 'ENGAGEMENT_WRITE_FAILED'));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (event.data?.type !== 'gala-session') return;
|
|
70
|
+
const user = event.data.user;
|
|
71
|
+
sessionUser.value = user && typeof user.id === 'string' ? user : null;
|
|
72
|
+
});
|
|
73
|
+
}
|