@rathnasgala/theme 0.0.1 → 0.0.5
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 +2 -2
- package/payload/.gala/managed-files.json +18 -15
- package/payload/eleventy.config.js +10 -2
- package/payload/lib/performance-budget.js +39 -0
- package/payload/lib/provider-fixtures/share-intents.v1.json +13 -0
- package/payload/lib/render-markdown.js +84 -5
- package/payload/lib/seo.js +11 -1
- package/payload/lib/share-targets.js +63 -0
- package/payload/lib/site-config.js +26 -1
- package/payload/package-lock.json +613 -1
- package/payload/package.json +5 -2
- package/payload/src/_includes/components/ui.njk +20 -1
- package/payload/src/_includes/layouts/base.njk +56 -5
- package/payload/src/_includes/layouts/post.njk +4 -3
- package/payload/src/assets/interactions.js +384 -0
- package/payload/src/assets/search.js +2 -4
- package/payload/src/assets/theme-mode.js +3 -1
- package/payload/src/assets/theme.css +60 -0
- package/payload/src/posts.11ty.js +26 -3
package/payload/package.json
CHANGED
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "eleventy",
|
|
8
8
|
"test": "node --test",
|
|
9
|
-
"lint": "node --check eleventy.config.js && node --check lib/build-manifest.js && node --check lib/engagement-snapshot.js && node --check lib/publication-state.js && node --check lib/render-markdown.js && node --check lib/seo.js && node --check lib/site-config.js && node --check src/_data/buildManifest.js && node --check src/_data/engagementSnapshot.js && node --check src/_data/feedLinks.js && node --check src/_data/languages.js && node --check src/_data/site.js && node --check src/assets/interactions.js && node --check src/assets/preferences.js && node --check src/assets/search.js && node --check src/assets/theme-mode.js && node --check src/feed.11ty.js && node --check src/languages.11ty.js && node --check src/posts.11ty.js && node --check src/redirects.11ty.js && node --check src/search-index.11ty.js && node --check src/sitemap.11ty.js && node --check test/build-manifest.test.js && node --check test/component-contract.test.js && node --check test/config.test.js && node --check test/eleventy-manifest-build.test.js && node --check test/engagement-snapshot.test.js && node --check test/feed.test.js && node --check test/fixtures/xss-payloads.js && node --check test/managed-files.test.js && node --check test/preferences.test.js && node --check test/publication-state.test.js && node --check test/render-markdown.test.js && node --check test/search.test.js && node --check test/seo.test.js && node --check test/supporting-surfaces.test.js && node --check test/theme-contract.test.js && node --check test/theme-contrast.test.js && node --check test/theme-mode.test.js"
|
|
9
|
+
"lint": "node --check eleventy.config.js && node --check lib/build-manifest.js && node --check lib/engagement-snapshot.js && node --check lib/performance-budget.js && node --check lib/publication-state.js && node --check lib/render-markdown.js && node --check lib/seo.js && node --check lib/share-targets.js && node --check lib/site-config.js && node --check scripts/push.js && node --check src/_data/buildManifest.js && node --check src/_data/engagementSnapshot.js && node --check src/_data/feedLinks.js && node --check src/_data/languages.js && node --check src/_data/site.js && node --check src/assets/interactions.js && node --check src/assets/preferences.js && node --check src/assets/search.js && node --check src/assets/theme-mode.js && node --check src/feed.11ty.js && node --check src/languages.11ty.js && node --check src/posts.11ty.js && node --check src/redirects.11ty.js && node --check src/search-index.11ty.js && node --check src/sitemap.11ty.js && node --check test/build-manifest.test.js && node --check test/component-contract.test.js && node --check test/config.test.js && node --check test/eleventy-manifest-build.test.js && node --check test/engagement-client.test.js && node --check test/engagement-snapshot.test.js && node --check test/feed.test.js && node --check test/fixtures/xss-payloads.js && node --check test/managed-files.test.js && node --check test/performance-budget.test.js && node --check test/preferences.test.js && node --check test/publication-state.test.js && node --check test/render-markdown.test.js && node --check test/search.test.js && node --check test/seo.test.js && node --check test/share-targets.test.js && node --check test/supporting-surfaces.test.js && node --check test/theme-contract.test.js && node --check test/theme-contrast.test.js && node --check test/theme-mode.test.js && node --check test/theme-release-workflow.test.js",
|
|
10
|
+
"push": "node scripts/push.js"
|
|
10
11
|
},
|
|
11
12
|
"engines": {
|
|
12
|
-
"node": ">=
|
|
13
|
+
"node": ">=20"
|
|
13
14
|
},
|
|
14
15
|
"devDependencies": {
|
|
15
16
|
"@11ty/eleventy": "3.1.6",
|
|
17
|
+
"github-slugger": "2.0.0",
|
|
16
18
|
"markdown-it": "15.0.0",
|
|
17
19
|
"markdown-it-footnote": "4.0.0",
|
|
18
20
|
"sanitize-html": "2.17.6",
|
|
21
|
+
"shiki": "4.4.3",
|
|
19
22
|
"yaml": "2.9.0"
|
|
20
23
|
}
|
|
21
24
|
}
|
|
@@ -13,5 +13,24 @@
|
|
|
13
13
|
{% macro tableOfContents(items) %}<nav class="gala-toc" aria-label="Table of contents"><ol>{% for item in items %}<li><a href="#{{ item.id }}">{{ item.text }}</a></li>{% endfor %}</ol></nav>{% endmacro %}
|
|
14
14
|
{% macro shareControl(canonicalUrl, targets=[]) %}<section class="gala-share" aria-label="Share this article"><button type="button" data-copy-url="{{ canonicalUrl | escape }}" aria-label="Copy canonical URL">Copy link</button><output class="gala-share__status" aria-live="polite"></output><input class="gala-share__fallback" value="{{ canonicalUrl | escape }}" readonly aria-label="Canonical URL">{% if targets.length %}<ul>{% for target in targets %}<li><a href="{{ target.url | escape }}" rel="noopener noreferrer" aria-label="Share on {{ target.label | escape }}">{{ target.label }}</a></li>{% endfor %}</ul>{% endif %}</section>{% endmacro %}
|
|
15
15
|
{% macro languageSwitcher(links, currentLanguage) %}{% if links.length > 2 %}<label>Preferred language <select data-language-preference data-navigate-on-selection>{% for link in links %}{% if link.hreflang != "x-default" %}<option value="{{ link.hreflang | escape }}" data-url="{{ link.href | escape }}"{% if link.hreflang == currentLanguage %} selected{% endif %}>{{ link.hreflang }}</option>{% endif %}{% endfor %}</select></label>{% endif %}{% endmacro %}
|
|
16
|
-
{% macro engagementSummary(articleId, engagement) %}
|
|
16
|
+
{% macro engagementSummary(articleId, engagement, apiBaseUrl) %}
|
|
17
|
+
<section class="gala-engagement" aria-label="Engagement" data-article-id="{{ articleId | escape }}" data-engagement-url="{{ apiBaseUrl | escape }}/v1/articles/{{ articleId | escape }}/engagement">
|
|
18
|
+
{% if engagement.available %}<dl data-engagement-snapshot><div><dt>Reactions</dt><dd>{{ engagement.reactions }}</dd></div><div><dt>Comments</dt><dd>{{ engagement.comments }}</dd></div><div><dt>Views</dt><dd>{{ engagement.views }}</dd></div></dl>{% else %}<p class="gala-engagement__placeholder" role="status">Loading engagement data.</p>{% endif %}
|
|
19
|
+
<div data-engagement-live></div>
|
|
20
|
+
<section class="gala-engagement-actions" aria-label="Reader actions" data-engagement-actions hidden>
|
|
21
|
+
<div class="gala-reaction-actions" role="group" aria-label="React to this article">
|
|
22
|
+
{% for reaction in ['like', 'love', 'insightful', 'curious', 'celebrate', 'support'] %}<button type="button" data-reaction="{{ reaction }}" aria-pressed="false">{{ reaction | capitalize }}</button>{% endfor %}
|
|
23
|
+
</div>
|
|
24
|
+
<button type="button" data-follow-article aria-pressed="false">Follow article</button>
|
|
25
|
+
<form data-comment-create>
|
|
26
|
+
<label>Comment<textarea name="body" maxlength="5000" required></textarea></label>
|
|
27
|
+
<p data-comment-reply-context hidden></p>
|
|
28
|
+
<button type="submit">Post comment</button>
|
|
29
|
+
<button type="button" data-cancel-reply hidden>Cancel reply</button>
|
|
30
|
+
</form>
|
|
31
|
+
</section>
|
|
32
|
+
<p data-engagement-sign-in>Sign in from the account menu to comment, react, or follow.</p>
|
|
33
|
+
<output data-engagement-status aria-live="polite"></output>
|
|
34
|
+
</section>
|
|
35
|
+
{% endmacro %}
|
|
17
36
|
{% macro pageFooter(siteName, links=[]) %}<footer class="gala-page-footer"><p>© {{ siteName }}</p>{% if links.length %}<nav aria-label="Footer"><ul>{% for link in links %}<li><a href="{{ link.url }}">{{ link.label }}</a></li>{% endfor %}</ul></nav>{% endif %}</footer>{% endmacro %}
|
|
@@ -27,14 +27,65 @@
|
|
|
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
|
-
|
|
30
|
+
<script src="{{ '/assets/search.js' | url }}" defer></script>
|
|
31
31
|
</head>
|
|
32
32
|
<body>
|
|
33
|
-
<header>
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
<header class="gala-site-header">
|
|
34
|
+
<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 11.5 12 4l9 7.5v8a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>
|
|
36
|
+
</a>
|
|
37
|
+
<nav class="gala-site-header__actions" aria-label="Site controls">
|
|
38
|
+
<button class="gala-icon-control" type="button" data-theme-mode-toggle aria-label="Color mode: system. Activate for light." title="Appearance">
|
|
39
|
+
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 0 18V3Zm0 2v14a7 7 0 0 1 0-14Z"/></svg>
|
|
40
|
+
<span class="gala-visually-hidden" data-theme-mode-label>Theme: system</span>
|
|
41
|
+
</button>
|
|
42
|
+
<button class="gala-icon-control" type="button" data-open-dialog="gala-settings-dialog" aria-label="Settings" title="Settings">
|
|
43
|
+
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M10.6 2h2.8l.7 2.2 1.5.9 2.3-.5 1.4 2.4-1.6 1.7v1.7l1.6 1.7-1.4 2.4-2.3-.5-1.5.9-.7 2.2h-2.8l-.7-2.2-1.5-.9-2.3.5-1.4-2.4 1.6-1.7V8.7L4.7 7l1.4-2.4 2.3.5 1.5-.9.7-2.2Zm1.4 6.5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>
|
|
44
|
+
</button>
|
|
45
|
+
<button class="gala-icon-control" type="button" data-open-dialog="gala-search-dialog" aria-label="Search" title="Search">
|
|
46
|
+
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M10.5 3a7.5 7.5 0 1 0 4.7 13.3l4.8 4.8 1.4-1.4-4.8-4.8A7.5 7.5 0 0 0 10.5 3Zm0 2a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Z"/></svg>
|
|
47
|
+
</button>
|
|
48
|
+
<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">
|
|
49
|
+
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9Zm0 11c-5 0-8 2.5-8 5.5V21h16v-1.5C20 16.5 17 14 12 14Z"/></svg>
|
|
50
|
+
</button>
|
|
51
|
+
</nav>
|
|
37
52
|
</header>
|
|
38
53
|
<main id="main-content">{{ content | safe }}</main>
|
|
54
|
+
<dialog id="gala-settings-dialog" class="gala-dialog" aria-labelledby="gala-settings-title">
|
|
55
|
+
<form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close settings">Close</button></form>
|
|
56
|
+
<h2 id="gala-settings-title">Reader settings</h2>
|
|
57
|
+
<section aria-labelledby="gala-dialog-theme-setting">
|
|
58
|
+
<h3 id="gala-dialog-theme-setting">Appearance</h3>
|
|
59
|
+
<button type="button" data-theme-mode-toggle aria-label="Color mode: system. Activate for light."><span data-theme-mode-label>Theme: system</span></button>
|
|
60
|
+
</section>
|
|
61
|
+
<section aria-labelledby="gala-dialog-language-setting">
|
|
62
|
+
<h3 id="gala-dialog-language-setting">Language preference</h3>
|
|
63
|
+
<label>Preferred language
|
|
64
|
+
<select data-language-preference>{% for language in languages %}<option value="{{ language | escape }}">{{ language }}</option>{% endfor %}</select>
|
|
65
|
+
</label>
|
|
66
|
+
</section>
|
|
67
|
+
<p><a href="{{ '/settings/' | url }}">Open settings page</a></p>
|
|
68
|
+
</dialog>
|
|
69
|
+
<dialog id="gala-search-dialog" class="gala-dialog" aria-labelledby="gala-search-title">
|
|
70
|
+
<form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close search">Close</button></form>
|
|
71
|
+
<h2 id="gala-search-title">Search</h2>
|
|
72
|
+
<section data-gala-search data-index-url="{{ '/search-index.json' | url }}">
|
|
73
|
+
<form class="gala-search" action="{{ '/search/' | url }}" role="search">
|
|
74
|
+
<label for="gala-dialog-search-query">Search published posts</label>
|
|
75
|
+
<input id="gala-dialog-search-query" name="q" type="search" autocomplete="off">
|
|
76
|
+
<button type="submit">Search</button>
|
|
77
|
+
</form>
|
|
78
|
+
<p data-search-status role="status" aria-live="polite">Enter a search term.</p>
|
|
79
|
+
<ol class="gala-search-results" data-search-results></ol>
|
|
80
|
+
</section>
|
|
81
|
+
<p><a href="{{ '/search/' | url }}">Open search page</a></p>
|
|
82
|
+
</dialog>
|
|
83
|
+
{% if site.site.id and site.site.id != "unavailable" %}
|
|
84
|
+
<dialog id="gala-account-dialog" class="gala-dialog gala-account-dialog" aria-labelledby="gala-account-title">
|
|
85
|
+
<form method="dialog" class="gala-dialog__close"><button type="submit" aria-label="Close account">Close</button></form>
|
|
86
|
+
<h2 id="gala-account-title">Gala account</h2>
|
|
87
|
+
<iframe data-gala-session-frame title="Gala account and sign in" src="{{ site.platform.apiBaseUrl or 'https://api.gala67.com' }}/v1/widget/session?siteId={{ site.site.id | escape }}"></iframe>
|
|
88
|
+
</dialog>
|
|
89
|
+
{% endif %}
|
|
39
90
|
</body>
|
|
40
91
|
</html>
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
layout: layouts/base.njk
|
|
3
3
|
---
|
|
4
|
-
{% from "components/ui.njk" import engagementSummary, languageSwitcher, shareControl %}
|
|
4
|
+
{% from "components/ui.njk" import engagementSummary, languageSwitcher, shareControl, tableOfContents %}
|
|
5
5
|
<article>
|
|
6
6
|
{% if post.publicationState == "published" %}<h1>{{ post.frontmatter.title }}</h1>{% endif %}
|
|
7
7
|
{% if post.publicationState == "published" %}{{ languageSwitcher(hreflangLinks, post.language) }}{% endif %}
|
|
8
|
+
{% if postTableOfContents.length %}{{ tableOfContents(postTableOfContents) }}{% endif %}
|
|
8
9
|
{{ content | safe }}
|
|
9
|
-
{% if post.publicationState == "published" %}{{ engagementSummary(post.id, engagement) }}{% endif %}
|
|
10
|
-
{% if post.publicationState == "published" %}{{ shareControl(post.canonicalUrl) }}{% endif %}
|
|
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 %}
|
|
11
12
|
</article>
|
|
@@ -8,6 +8,96 @@ function selectableFallback(control) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
document.addEventListener('click', async (event) => {
|
|
11
|
+
const dialogControl = event.target.closest('[data-open-dialog]');
|
|
12
|
+
if (dialogControl) {
|
|
13
|
+
const dialog = document.getElementById(dialogControl.dataset.openDialog);
|
|
14
|
+
if (dialog instanceof HTMLDialogElement) dialog.showModal();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const reaction = event.target.closest('[data-reaction]');
|
|
19
|
+
if (reaction) {
|
|
20
|
+
const region = reaction.closest('[data-engagement-url]');
|
|
21
|
+
if (!region) return;
|
|
22
|
+
const active = reaction.getAttribute('aria-pressed') !== 'true';
|
|
23
|
+
const saved = await mutateEngagement(region, active ? 'reaction.add' : 'reaction.remove', {
|
|
24
|
+
articleId: region.dataset.articleId,
|
|
25
|
+
reaction: reaction.dataset.reaction
|
|
26
|
+
});
|
|
27
|
+
if (!saved) return;
|
|
28
|
+
reaction.setAttribute('aria-pressed', String(active));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const follow = event.target.closest('[data-follow-article]');
|
|
33
|
+
if (follow) {
|
|
34
|
+
const region = follow.closest('[data-engagement-url]');
|
|
35
|
+
if (!region) return;
|
|
36
|
+
const active = follow.getAttribute('aria-pressed') !== 'true';
|
|
37
|
+
const saved = await mutateEngagement(region, active ? 'follow.add' : 'follow.remove', {
|
|
38
|
+
targetType: 'articles', targetId: region.dataset.articleId
|
|
39
|
+
});
|
|
40
|
+
if (!saved) return;
|
|
41
|
+
follow.setAttribute('aria-pressed', String(active));
|
|
42
|
+
follow.textContent = active ? 'Unfollow article' : 'Follow article';
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const reply = event.target.closest('[data-reply-comment]');
|
|
47
|
+
if (reply) {
|
|
48
|
+
const region = reply.closest('[data-engagement-url]');
|
|
49
|
+
const form = region?.querySelector('[data-comment-create]');
|
|
50
|
+
if (!form) return;
|
|
51
|
+
form.dataset.parentCommentId = reply.dataset.replyComment;
|
|
52
|
+
const context = form.querySelector('[data-comment-reply-context]');
|
|
53
|
+
if (context) {
|
|
54
|
+
context.hidden = false;
|
|
55
|
+
context.textContent = `Replying to ${reply.dataset.replyAuthor || 'comment'}.`;
|
|
56
|
+
}
|
|
57
|
+
const cancel = form.querySelector('[data-cancel-reply]');
|
|
58
|
+
if (cancel) cancel.hidden = false;
|
|
59
|
+
form.querySelector('textarea')?.focus();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const cancelReply = event.target.closest('[data-cancel-reply]');
|
|
64
|
+
if (cancelReply) {
|
|
65
|
+
clearReply(cancelReply.closest('[data-comment-create]'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const edit = event.target.closest('[data-edit-comment]');
|
|
70
|
+
if (edit) {
|
|
71
|
+
showCommentEditor(edit);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cancelEdit = event.target.closest('[data-cancel-comment-edit]');
|
|
76
|
+
if (cancelEdit) {
|
|
77
|
+
cancelEdit.closest('[data-comment-editor]')?.remove();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const remove = event.target.closest('[data-delete-comment]');
|
|
82
|
+
if (remove) {
|
|
83
|
+
const region = remove.closest('[data-engagement-url]');
|
|
84
|
+
if (!region) return;
|
|
85
|
+
await mutateEngagement(region, 'comment.delete', {
|
|
86
|
+
articleId: region.dataset.articleId,
|
|
87
|
+
commentId: remove.dataset.deleteComment
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const moreComments = event.target.closest('[data-comments-cursor]');
|
|
93
|
+
if (moreComments) {
|
|
94
|
+
const region = moreComments.closest('[data-engagement-url]');
|
|
95
|
+
if (!region) return;
|
|
96
|
+
moreComments.disabled = true;
|
|
97
|
+
await refreshEngagement(region, moreComments.dataset.commentsCursor, true);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
11
101
|
const share = event.target.closest('[data-copy-url]');
|
|
12
102
|
if (share) {
|
|
13
103
|
const value = share.dataset.copyUrl;
|
|
@@ -52,3 +142,297 @@ codeBlocks.forEach((pre) => {
|
|
|
52
142
|
control.setAttribute('aria-label', 'Copy code block');
|
|
53
143
|
wrapper.prepend(control);
|
|
54
144
|
});
|
|
145
|
+
|
|
146
|
+
function textElement(name, text, className) {
|
|
147
|
+
const element = document.createElement(name);
|
|
148
|
+
element.textContent = text;
|
|
149
|
+
if (className) element.className = className;
|
|
150
|
+
return element;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function engagementCounts(data) {
|
|
154
|
+
const reactionTotal = Object.values(data.reactions ?? {})
|
|
155
|
+
.reduce((total, count) => total + (Number.isSafeInteger(count) ? count : 0), 0);
|
|
156
|
+
return [
|
|
157
|
+
['Reactions', reactionTotal],
|
|
158
|
+
['Comments', Number.isSafeInteger(data.comments?.totalCount) ? data.comments.totalCount : 0],
|
|
159
|
+
['Views', Number.isSafeInteger(data.views?.count) ? data.views.count : 0]
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function commentList(items, currentUser) {
|
|
164
|
+
const roots = document.createElement('ol');
|
|
165
|
+
roots.className = 'gala-comments';
|
|
166
|
+
const nodes = new Map();
|
|
167
|
+
for (const comment of items) {
|
|
168
|
+
const item = document.createElement('li');
|
|
169
|
+
item.dataset.commentId = comment.commentId;
|
|
170
|
+
const author = comment.author?.displayName ?? '[deleted]';
|
|
171
|
+
item.append(textElement('strong', author));
|
|
172
|
+
const body = textElement('p', comment.deleted ? '[deleted]' : comment.body);
|
|
173
|
+
body.dataset.commentBody = '';
|
|
174
|
+
item.append(body);
|
|
175
|
+
if (!comment.deleted && currentUser) {
|
|
176
|
+
const actions = document.createElement('div');
|
|
177
|
+
actions.className = 'gala-comment-actions';
|
|
178
|
+
const reply = textElement('button', 'Reply');
|
|
179
|
+
reply.type = 'button';
|
|
180
|
+
reply.dataset.replyComment = comment.commentId;
|
|
181
|
+
reply.dataset.replyAuthor = author;
|
|
182
|
+
actions.append(reply);
|
|
183
|
+
if (comment.author?.userId === currentUser.id) {
|
|
184
|
+
const edit = textElement('button', 'Edit');
|
|
185
|
+
edit.type = 'button';
|
|
186
|
+
edit.dataset.editComment = comment.commentId;
|
|
187
|
+
const remove = textElement('button', 'Delete');
|
|
188
|
+
remove.type = 'button';
|
|
189
|
+
remove.dataset.deleteComment = comment.commentId;
|
|
190
|
+
actions.append(edit, remove);
|
|
191
|
+
}
|
|
192
|
+
item.append(actions);
|
|
193
|
+
}
|
|
194
|
+
nodes.set(comment.commentId, item);
|
|
195
|
+
}
|
|
196
|
+
for (const comment of items) {
|
|
197
|
+
const item = nodes.get(comment.commentId);
|
|
198
|
+
const parent = comment.parentCommentId ? nodes.get(comment.parentCommentId) : null;
|
|
199
|
+
if (!parent) {
|
|
200
|
+
roots.append(item);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
let replies = parent.querySelector(':scope > ol');
|
|
204
|
+
if (!replies) {
|
|
205
|
+
replies = document.createElement('ol');
|
|
206
|
+
replies.className = 'gala-comment-replies';
|
|
207
|
+
parent.append(replies);
|
|
208
|
+
}
|
|
209
|
+
replies.append(item);
|
|
210
|
+
}
|
|
211
|
+
return roots;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function renderEngagement(region, payload, appendComments = false) {
|
|
215
|
+
const data = payload?.data;
|
|
216
|
+
if (!data || typeof data !== 'object') throw new TypeError('Engagement data is invalid');
|
|
217
|
+
const live = region.querySelector('[data-engagement-live]');
|
|
218
|
+
if (!live) return;
|
|
219
|
+
if (!appendComments) live.replaceChildren();
|
|
220
|
+
|
|
221
|
+
if (!appendComments && data.profile) {
|
|
222
|
+
const profile = document.createElement('section');
|
|
223
|
+
profile.className = 'gala-engagement-profile';
|
|
224
|
+
profile.setAttribute('aria-label', 'Author profile');
|
|
225
|
+
profile.append(textElement('h2', data.profile.displayName));
|
|
226
|
+
if (data.profile.username) profile.append(textElement('p', `@${data.profile.username}`));
|
|
227
|
+
if (Number.isSafeInteger(data.profile.followerCount)) {
|
|
228
|
+
profile.append(textElement('p', `${data.profile.followerCount} followers`));
|
|
229
|
+
}
|
|
230
|
+
live.append(profile);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!appendComments) {
|
|
234
|
+
const summary = document.createElement('dl');
|
|
235
|
+
for (const [label, value] of engagementCounts(data)) {
|
|
236
|
+
const item = document.createElement('div');
|
|
237
|
+
item.append(textElement('dt', label), textElement('dd', String(value)));
|
|
238
|
+
summary.append(item);
|
|
239
|
+
}
|
|
240
|
+
live.append(summary);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (Array.isArray(data.comments?.items) && data.comments.items.length > 0) {
|
|
244
|
+
live.append(commentList(data.comments.items, sessionUser));
|
|
245
|
+
}
|
|
246
|
+
live.querySelector('[data-comments-cursor]')?.remove();
|
|
247
|
+
if (data.comments?.nextCursor) {
|
|
248
|
+
const more = textElement('button', 'Load more comments');
|
|
249
|
+
more.type = 'button';
|
|
250
|
+
more.dataset.commentsCursor = data.comments.nextCursor;
|
|
251
|
+
live.append(more);
|
|
252
|
+
}
|
|
253
|
+
region.querySelector('[data-engagement-snapshot]')?.remove();
|
|
254
|
+
region.querySelector('.gala-engagement__placeholder')?.remove();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function clearReply(form) {
|
|
258
|
+
if (!form) return;
|
|
259
|
+
delete form.dataset.parentCommentId;
|
|
260
|
+
const context = form.querySelector('[data-comment-reply-context]');
|
|
261
|
+
if (context) {
|
|
262
|
+
context.hidden = true;
|
|
263
|
+
context.textContent = '';
|
|
264
|
+
}
|
|
265
|
+
const cancel = form.querySelector('[data-cancel-reply]');
|
|
266
|
+
if (cancel) cancel.hidden = true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function showCommentEditor(control) {
|
|
270
|
+
const item = control.closest('[data-comment-id]');
|
|
271
|
+
if (!item || item.querySelector('[data-comment-editor]')) return;
|
|
272
|
+
const form = document.createElement('form');
|
|
273
|
+
form.dataset.commentEditor = control.dataset.editComment;
|
|
274
|
+
const textarea = document.createElement('textarea');
|
|
275
|
+
textarea.name = 'body';
|
|
276
|
+
textarea.maxLength = 5000;
|
|
277
|
+
textarea.required = true;
|
|
278
|
+
textarea.value = item.querySelector('[data-comment-body]')?.textContent ?? '';
|
|
279
|
+
const save = textElement('button', 'Save');
|
|
280
|
+
save.type = 'submit';
|
|
281
|
+
const cancel = textElement('button', 'Cancel');
|
|
282
|
+
cancel.type = 'button';
|
|
283
|
+
cancel.dataset.cancelCommentEdit = '';
|
|
284
|
+
form.append(textarea, save, cancel);
|
|
285
|
+
item.append(form);
|
|
286
|
+
textarea.focus();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function refreshEngagement(region, commentsCursor = '', appendComments = false) {
|
|
290
|
+
const status = region.querySelector('[data-engagement-status]');
|
|
291
|
+
try {
|
|
292
|
+
const requestUrl = new URL(region.dataset.engagementUrl);
|
|
293
|
+
if (commentsCursor) requestUrl.searchParams.set('commentsCursor', commentsCursor);
|
|
294
|
+
const response = await fetch(requestUrl, {
|
|
295
|
+
headers: { Accept: 'application/json' },
|
|
296
|
+
credentials: 'omit'
|
|
297
|
+
});
|
|
298
|
+
if (!response.ok) throw new Error(`Engagement returned HTTP ${response.status}`);
|
|
299
|
+
const payload = await response.json();
|
|
300
|
+
renderEngagement(region, payload, appendComments);
|
|
301
|
+
if (status) status.textContent = payload.errors?.length
|
|
302
|
+
? 'Some engagement data is temporarily unavailable.' : '';
|
|
303
|
+
} catch {
|
|
304
|
+
if (status) status.textContent = 'Live engagement data is temporarily unavailable.';
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function viewCampaign() {
|
|
309
|
+
const query = new URL(window.location.href).searchParams;
|
|
310
|
+
const campaign = {};
|
|
311
|
+
for (const name of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
|
|
312
|
+
const value = query.get(name);
|
|
313
|
+
if (value && [...value].length <= 128) campaign[name] = value;
|
|
314
|
+
}
|
|
315
|
+
return campaign;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function recordView(region) {
|
|
319
|
+
const requestUrl = new URL(region.dataset.engagementUrl);
|
|
320
|
+
requestUrl.pathname = requestUrl.pathname.replace(/\/engagement$/, '/views');
|
|
321
|
+
fetch(requestUrl, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'Content-Type': 'application/json' },
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
language: document.documentElement.lang || undefined,
|
|
326
|
+
referrer: document.referrer || undefined,
|
|
327
|
+
campaign: viewCampaign()
|
|
328
|
+
}),
|
|
329
|
+
credentials: 'omit',
|
|
330
|
+
keepalive: true
|
|
331
|
+
}).catch(() => {});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
document.addEventListener('submit', async (event) => {
|
|
335
|
+
const create = event.target.closest('[data-comment-create]');
|
|
336
|
+
const editor = event.target.closest('[data-comment-editor]');
|
|
337
|
+
if (!create && !editor) return;
|
|
338
|
+
event.preventDefault();
|
|
339
|
+
const form = create ?? editor;
|
|
340
|
+
const region = form.closest('[data-engagement-url]');
|
|
341
|
+
const body = form.elements.body?.value;
|
|
342
|
+
if (!region || typeof body !== 'string' || body.trim() === '') return;
|
|
343
|
+
if (create) {
|
|
344
|
+
const saved = await mutateEngagement(region, 'comment.create', {
|
|
345
|
+
articleId: region.dataset.articleId,
|
|
346
|
+
parentCommentId: create.dataset.parentCommentId || null,
|
|
347
|
+
body
|
|
348
|
+
});
|
|
349
|
+
if (!saved) return;
|
|
350
|
+
create.reset();
|
|
351
|
+
clearReply(create);
|
|
352
|
+
} else {
|
|
353
|
+
const saved = await mutateEngagement(region, 'comment.edit', {
|
|
354
|
+
articleId: region.dataset.articleId,
|
|
355
|
+
commentId: editor.dataset.commentEditor,
|
|
356
|
+
body
|
|
357
|
+
});
|
|
358
|
+
if (!saved) return;
|
|
359
|
+
editor.remove();
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
let sessionUser = null;
|
|
364
|
+
const pendingEngagementWrites = new Map();
|
|
365
|
+
|
|
366
|
+
document.querySelectorAll('[data-engagement-url]').forEach((region) => {
|
|
367
|
+
refreshEngagement(region);
|
|
368
|
+
recordView(region);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const sessionFrame = document.querySelector('[data-gala-session-frame]');
|
|
372
|
+
|
|
373
|
+
function engagementErrorMessage(code) {
|
|
374
|
+
if (code === 'AUTHENTICATION_REQUIRED' || code === 'INVALID_BEARER_TOKEN'
|
|
375
|
+
|| code === 'REAUTHENTICATION_REQUIRED') return 'Sign in again to continue.';
|
|
376
|
+
if (code === 'ENGAGEMENT_RATE_LIMITED') return 'You are posting too quickly; try again shortly.';
|
|
377
|
+
if (code === 'RESOURCE_NOT_FOUND') return 'That item is no longer available.';
|
|
378
|
+
if (code === 'INVALID_ENGAGEMENT_WRITE' || code === 'INVALID_REQUEST') return 'Check your entry and try again.';
|
|
379
|
+
if (code === 'ACCESS_DENIED') return 'That action is not available for this account.';
|
|
380
|
+
if (code === 'IDEMPOTENCY_CONFLICT' || code === 'ENGAGEMENT_STATE_CONFLICT') return 'The item changed; reload and try again.';
|
|
381
|
+
return 'The action could not be completed. Try again.';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function sendEngagementWrite(operation, payload) {
|
|
385
|
+
if (!sessionFrame || !sessionUser) return Promise.reject(new Error('AUTHENTICATION_REQUIRED'));
|
|
386
|
+
const requestId = crypto.randomUUID();
|
|
387
|
+
return new Promise((resolve, reject) => {
|
|
388
|
+
pendingEngagementWrites.set(requestId, { resolve, reject });
|
|
389
|
+
sessionFrame.contentWindow.postMessage({
|
|
390
|
+
type: 'gala-engagement-write', requestId, operation, payload
|
|
391
|
+
}, new URL(sessionFrame.src).origin);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function mutateEngagement(region, operation, payload) {
|
|
396
|
+
const status = region.querySelector('[data-engagement-status]');
|
|
397
|
+
try {
|
|
398
|
+
if (status) status.textContent = 'Saving…';
|
|
399
|
+
await sendEngagementWrite(operation, payload);
|
|
400
|
+
if (status) status.textContent = 'Saved.';
|
|
401
|
+
await refreshEngagement(region);
|
|
402
|
+
return true;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (status) status.textContent = engagementErrorMessage(error.message);
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (sessionFrame) {
|
|
410
|
+
const sessionOrigin = new URL(sessionFrame.src).origin;
|
|
411
|
+
window.addEventListener('message', (event) => {
|
|
412
|
+
if (event.origin !== sessionOrigin || event.source !== sessionFrame.contentWindow) return;
|
|
413
|
+
if (event.data?.type === 'gala-engagement-result') {
|
|
414
|
+
const pending = pendingEngagementWrites.get(event.data.requestId);
|
|
415
|
+
if (!pending) return;
|
|
416
|
+
pendingEngagementWrites.delete(event.data.requestId);
|
|
417
|
+
if (event.data.ok === true) pending.resolve(event.data.result);
|
|
418
|
+
else pending.reject(new Error(event.data.error?.code || 'ENGAGEMENT_WRITE_FAILED'));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (event.data?.type !== 'gala-session') return;
|
|
422
|
+
sessionUser = event.data.user && typeof event.data.user.id === 'string' ? event.data.user : null;
|
|
423
|
+
const control = document.querySelector('[data-user-control]');
|
|
424
|
+
const displayName = sessionUser?.displayName;
|
|
425
|
+
if (control) {
|
|
426
|
+
control.setAttribute('aria-label', displayName
|
|
427
|
+
? `Account: ${displayName}` : 'Sign in or view account');
|
|
428
|
+
control.title = displayName ? `Account: ${displayName}` : 'Account';
|
|
429
|
+
}
|
|
430
|
+
document.querySelectorAll('[data-engagement-url]').forEach((region) => {
|
|
431
|
+
const actions = region.querySelector('[data-engagement-actions]');
|
|
432
|
+
const signIn = region.querySelector('[data-engagement-sign-in]');
|
|
433
|
+
if (actions) actions.hidden = !sessionUser;
|
|
434
|
+
if (signIn) signIn.hidden = Boolean(sessionUser);
|
|
435
|
+
refreshEngagement(region);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
if (search) {
|
|
1
|
+
document.querySelectorAll('[data-gala-search]').forEach((search) => {
|
|
4
2
|
const form = search.querySelector('form');
|
|
5
3
|
const query = form?.elements.namedItem('q');
|
|
6
4
|
const status = search.querySelector('[data-search-status]');
|
|
@@ -78,4 +76,4 @@ if (search) {
|
|
|
78
76
|
const initial = new URLSearchParams(window.location.search).get('q') ?? '';
|
|
79
77
|
if (query) query.value = initial;
|
|
80
78
|
run(initial);
|
|
81
|
-
}
|
|
79
|
+
});
|
|
@@ -14,7 +14,9 @@ function applyMode(mode) {
|
|
|
14
14
|
document.documentElement.dataset.mode = mode;
|
|
15
15
|
const next = MODES[(MODES.indexOf(mode) + 1) % MODES.length];
|
|
16
16
|
document.querySelectorAll('[data-theme-mode-toggle]').forEach((control) => {
|
|
17
|
-
|
|
17
|
+
const label = control.querySelector?.('[data-theme-mode-label]');
|
|
18
|
+
if (label) label.textContent = `Theme: ${mode}`;
|
|
19
|
+
else control.textContent = `Theme: ${mode}`;
|
|
18
20
|
control.setAttribute('aria-label', `Color mode: ${mode}. Activate for ${next}.`);
|
|
19
21
|
});
|
|
20
22
|
}
|
|
@@ -38,6 +38,45 @@ body {
|
|
|
38
38
|
font-family: var(--gala-font-body);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
.gala-site-header {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: space-between;
|
|
45
|
+
max-inline-size: var(--gala-content-width);
|
|
46
|
+
margin-inline: auto;
|
|
47
|
+
margin-block-end: 1.5rem;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.gala-site-header__actions { display: flex; gap: 0.5rem; }
|
|
51
|
+
.gala-icon-control {
|
|
52
|
+
display: inline-grid;
|
|
53
|
+
inline-size: 2.75rem;
|
|
54
|
+
block-size: 2.75rem;
|
|
55
|
+
box-sizing: border-box;
|
|
56
|
+
padding: 0.6rem;
|
|
57
|
+
place-items: center;
|
|
58
|
+
color: inherit;
|
|
59
|
+
border: 0.125rem solid transparent;
|
|
60
|
+
border-radius: 50%;
|
|
61
|
+
}
|
|
62
|
+
.gala-icon-control:hover { background: var(--gala-color-surface); }
|
|
63
|
+
.gala-icon-control:focus-visible { outline: 0.1875rem solid currentcolor; outline-offset: 0.1875rem; }
|
|
64
|
+
.gala-icon-control svg { inline-size: 100%; block-size: 100%; fill: currentcolor; }
|
|
65
|
+
|
|
66
|
+
.gala-dialog {
|
|
67
|
+
inline-size: min(42rem, calc(100% - 2rem));
|
|
68
|
+
max-block-size: calc(100% - 2rem);
|
|
69
|
+
box-sizing: border-box;
|
|
70
|
+
padding: 1.5rem;
|
|
71
|
+
color: var(--gala-color-text);
|
|
72
|
+
background: var(--gala-color-background);
|
|
73
|
+
border: 0.125rem solid var(--gala-color-border);
|
|
74
|
+
border-radius: var(--gala-radius-control);
|
|
75
|
+
}
|
|
76
|
+
.gala-dialog::backdrop { background: rgb(0 0 0 / 65%); }
|
|
77
|
+
.gala-dialog__close { float: inline-end; }
|
|
78
|
+
.gala-account-dialog iframe { inline-size: 100%; min-block-size: 22rem; border: 0; }
|
|
79
|
+
|
|
41
80
|
main,
|
|
42
81
|
.gala-page-footer {
|
|
43
82
|
max-inline-size: var(--gala-content-width);
|
|
@@ -107,11 +146,32 @@ button:focus-visible {
|
|
|
107
146
|
.gala-engagement dl > div { display: grid; gap: 0.25rem; }
|
|
108
147
|
.gala-engagement dd { margin: 0; font-size: 1.25rem; font-weight: 700; }
|
|
109
148
|
.gala-engagement__placeholder { display: grid; min-block-size: var(--gala-widget-min-block-size); align-content: center; margin: 0; }
|
|
149
|
+
.gala-engagement-profile { margin-block: 1.5rem; padding: 1rem; background: var(--gala-color-surface); border-radius: var(--gala-radius-control); }
|
|
150
|
+
.gala-engagement-profile > * { margin-block: 0 0.5rem; }
|
|
151
|
+
.gala-comments { display: grid; gap: 1rem; padding-inline-start: 1.5rem; }
|
|
152
|
+
.gala-comments li { padding: 1rem; background: var(--gala-color-surface); border-radius: var(--gala-radius-control); }
|
|
153
|
+
.gala-engagement-actions { display: grid; gap: 1rem; margin-block-start: 1.5rem; }
|
|
154
|
+
.gala-engagement-actions[hidden] { display: none; }
|
|
155
|
+
.gala-reaction-actions,
|
|
156
|
+
.gala-comment-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
157
|
+
.gala-engagement-actions form,
|
|
158
|
+
[data-comment-editor] { display: grid; gap: 0.5rem; max-inline-size: 42rem; }
|
|
159
|
+
.gala-engagement-actions label { display: grid; gap: 0.5rem; }
|
|
160
|
+
.gala-engagement-actions textarea,
|
|
161
|
+
[data-comment-editor] textarea { min-block-size: 7rem; padding: 0.75rem; font: inherit; }
|
|
110
162
|
.gala-page-footer { margin-block-start: 4rem; padding-block-start: 1rem; border-block-start: 0.125rem solid var(--gala-color-border); }
|
|
111
163
|
.gala-error-page { min-block-size: 60vh; display: grid; align-content: center; justify-items: start; }
|
|
112
164
|
.gala-code-block { position: relative; }
|
|
113
165
|
.gala-code-block > button { position: absolute; inset-block-start: 0.5rem; inset-inline-end: 0.5rem; background: var(--gala-color-background); }
|
|
114
166
|
.gala-code-block pre { overflow: auto; padding: 3.5rem 1rem 1rem; background: var(--gala-color-surface); border-radius: var(--gala-radius-control); }
|
|
167
|
+
.shiki,
|
|
168
|
+
.shiki span { color: var(--shiki-light); background-color: var(--shiki-light-bg); }
|
|
169
|
+
:root[data-mode='dark'] .shiki,
|
|
170
|
+
:root[data-mode='dark'] .shiki span { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); }
|
|
171
|
+
@media (prefers-color-scheme: dark) {
|
|
172
|
+
:root[data-mode='system'] .shiki,
|
|
173
|
+
:root[data-mode='system'] .shiki span { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); }
|
|
174
|
+
}
|
|
115
175
|
.gala-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
116
176
|
|
|
117
177
|
@keyframes gala-spin { to { transform: rotate(1turn); } }
|