Two Website Updates
As I write this, my PhD defence is in less than 48 hours’ time! This last weekend I wanted to make sure I relaxed properly, so to avoid psyching myself out with intrusive thoughts I’ve been doing some housekeeping with the website for usability and adaptiveness. I then added a comments feature as well for good measure.
Housekeeping
The main update here was to tidy up dark mode. Previously it was a bit of a mess; some text and other elements would switch to light colours, but the background would stay light. No more! It should be working nicely and more easily on the eyes now.
While I was digging around my—admittedly dodgy—custom CSS, I also improved some of the site’s responsiveness to small screens. At least, I think I did. Please let me know if it isn’t working for you. How can you do that, you may ask?
Commenting on posts
Yes you can comment on my web pages now! In a way, at least. Because this is a static site made using Hugo, I can’t do comments in the normal way, with databases and PHP and so on. Instead, I’ve followed Carl Schwan’s instructions for linking a web article with a post on Mastodon. Replies to that post can then be loaded via the Mastodon API and fill the role of the article’s comment section.
Because my Mastodon profile is bridged to Bluesky, I also wanted to give bridged Bluesky accounts the option of commenting there instead. So, although my code is pretty much the same as Carl’s, I have tweaked it here and there for my purposes.
HTML code added to new file /layouts/partials/comments.html
{{ with .Params.comments }}
<section id="comments" class="article-content">
<h2>Comments</h2>
<p>You can use your Mastodon or other Fediverse account to comment on this post by replying to <a href="https://{{ .host }}/@{{ .username }}/{{ .id }}">this thread</a>.</p>
<p>Alternatively, you can reply to <a href="https://bsky.app/profile/{{ .bsky_username }}/post/{{ .bsky_id }}">this Bluesky thread</a> using a <a href="https://fed.brid.gy/docs#bluesky-get-started">bridged Bluesky account</a>.</p>
<p>Learn how this is implemented <a class="link" href="/2020/12/29/adding-comments-to-your-static-blog-with-mastodon/">here.</a></p>
<p id="mastodon-comments-list"><button id="load-comment">Load comments</button></p>
<div id="comments-wrapper">
<noscript><p>Loading comments relies on JavaScript. Try enabling JavaScript and reloading, or visit <a href="https://{{ .host }}/@{{ .username }}/{{ .id }}">the original post</a> on Mastodon.</p></noscript>
</div>
<noscript>You need JavaScript to view the comments.</noscript>
<script src="/assets/js/purify.min.js"></script>
<script type="text/javascript">
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function emojify(input, emojis) {
let output = input;
emojis.forEach(emoji => {
let picture = document.createElement("picture");
let source = document.createElement("source");
source.setAttribute("srcset", escapeHtml(emoji.url));
source.setAttribute("media", "(prefers-reduced-motion: no-preference)");
let img = document.createElement("img");
img.className = "emoji";
img.setAttribute("src", escapeHtml(emoji.static_url));
img.setAttribute("alt", `:${ emoji.shortcode }:`);
img.setAttribute("title", `:${ emoji.shortcode }:`);
img.setAttribute("width", "20");
img.setAttribute("height", "20");
picture.appendChild(source);
picture.appendChild(img);
output = output.replace(`:${ emoji.shortcode }:`, picture.outerHTML);
});
return output;
}
function loadComments() {
let commentsWrapper = document.getElementById("comments-wrapper");
document.getElementById("load-comment").innerHTML = "Loading";
fetch('https://{{ .host }}/api/v1/statuses/{{ .id }}/context')
.then(function(response) {
return response.json();
})
.then(function(data) {
let descendants = data['descendants'];
if(
descendants &&
Array.isArray(descendants) &&
descendants.length > 0
) {
commentsWrapper.innerHTML = "";
descendants.forEach(function(status) {
console.log(descendants)
if( status.account.display_name.length > 0 ) {
status.account.display_name = escapeHtml(status.account.display_name);
status.account.display_name = emojify(status.account.display_name, status.account.emojis);
} else {
status.account.display_name = status.account.username;
};
let instance = "";
if( status.account.acct.includes("@") ) {
instance = status.account.acct.split("@")[1];
} else {
instance = "{{ .host }}";
}
const isReply = status.in_reply_to_id !== "{{ .id }}";
let op = false;
if( status.account.acct == "{{ .username }}" ) {
op = true;
}
status.content = emojify(status.content, status.emojis);
let avatarSource = document.createElement("source");
avatarSource.setAttribute("srcset", escapeHtml(status.account.avatar));
avatarSource.setAttribute("media", "(prefers-reduced-motion: no-preference)");
let avatarImg = document.createElement("img");
avatarImg.className = "avatar";
avatarImg.setAttribute("src", escapeHtml(status.account.avatar_static));
avatarImg.setAttribute("alt", `@${ status.account.username }@${ instance } avatar`);
let avatarPicture = document.createElement("picture");
avatarPicture.appendChild(avatarSource);
avatarPicture.appendChild(avatarImg);
let avatar = document.createElement("a");
avatar.className = "avatar-link";
avatar.setAttribute("href", status.account.url);
avatar.setAttribute("rel", "external nofollow");
avatar.setAttribute("title", `View profile at @${ status.account.username }@${ instance }`);
avatar.appendChild(avatarPicture);
let instanceBadge = document.createElement("a");
instanceBadge.className = "instance";
instanceBadge.setAttribute("href", status.account.url);
instanceBadge.setAttribute("title", `@${ status.account.username }@${ instance }`);
instanceBadge.setAttribute("rel", "external nofollow");
instanceBadge.textContent = instance;
let display = document.createElement("span");
display.className = "display";
display.setAttribute("itemprop", "author");
display.setAttribute("itemtype", "http://schema.org/Person");
display.innerHTML = status.account.display_name;
let header = document.createElement("header");
header.className = "author";
header.appendChild(display);
header.appendChild(instanceBadge);
let permalink = document.createElement("a");
permalink.setAttribute("href", status.url);
permalink.setAttribute("itemprop", "url");
permalink.setAttribute("title", `View comment at ${ instance }`);
permalink.setAttribute("rel", "external nofollow");
permalink.textContent = new Date( status.created_at ).toLocaleString('en-US', {
dateStyle: "long",
timeStyle: "short",
});
let timestamp = document.createElement("time");
timestamp.setAttribute("datetime", status.created_at);
timestamp.appendChild(permalink);
let main = document.createElement("main");
main.setAttribute("itemprop", "text");
main.innerHTML = status.content;
let interactions = document.createElement("footer");
if(status.favourites_count > 0) {
let faves = document.createElement("a");
faves.className = "faves";
faves.setAttribute("href", `${ status.url }/favourites`);
faves.setAttribute("title", `Favorites from ${ instance }`);
faves.textContent = status.favourites_count;
interactions.appendChild(faves);
}
let comment = document.createElement("article");
comment.id = `comment-${ status.id }`;
comment.className = isReply ? "comment comment-reply" : "comment";
comment.setAttribute("itemprop", "comment");
comment.setAttribute("itemtype", "http://schema.org/Comment");
comment.appendChild(avatar);
comment.appendChild(header);
comment.appendChild(timestamp);
comment.appendChild(main);
comment.appendChild(interactions);
if(op === true) {
comment.classList.add("op");
avatar.classList.add("op");
avatar.setAttribute(
"title",
"Blog post author; " + avatar.getAttribute("title")
);
instanceBadge.classList.add("op");
instanceBadge.setAttribute(
"title",
"Blog post author: " + instanceBadge.getAttribute("title")
);
}
commentsWrapper.innerHTML += DOMPurify.sanitize(comment.outerHTML);
});
document.getElementById("load-comment").innerHTML = "Reload comments";
} else {
document.getElementById("load-comment").innerHTML = "No comments found";
}
});
}
document.getElementById("load-comment").addEventListener("click", loadComments);
</script>
</section>
{{ end }}
Updated version of /layouts/_default/default.html
{{ define "main" }}
<main>
<h1>{{ .Title }}</h1>
{{ partial "postMeta.html" . }}
{{ .Content }}
{{ partial "comments.html" . }}
</main>
{{ end }}
CSS added to /static/css/custom.css
.avatar {
background-position: center;
background-size: cover;
border-radius: 50%;
box-shadow: 0 0 2px var(--neutral);
margin: 0;
overflow: hidden;
}
section#comments {
#comments-wrapper {
margin: 1.5em 0;
padding: 0 25px;
}
.comment {
display: grid;
column-gap: 1rem;
grid-template-areas:
"avatar name"
"avatar time"
"avatar post"
"...... interactions";
grid-template-columns: min-content;
justify-items: start;
margin: 0em auto 0em -1em;
padding: 0.5em;
&.comment-reply {
margin: 0em auto 0em 2.5em;
}
.avatar-link {
grid-area: avatar;
height: 4rem;
position: relative;
width: 4rem;
.avatar {
height: 100%;
width: 100%;
}
&.op::after {
background-color: var(--prebgcolor);
border-radius: 50%;
bottom: -0.25rem;
color: var(--accentcolortext);
content: "✓";
display: block;
font-size: 1.25rem;
font-weight: bold;
height: 1.5rem;
line-height: 1.5rem;
position: absolute;
right: -0.25rem;
text-align: center;
width: 1.5rem;
}
}
.author {
align-items: center;
display: flex;
font-weight: bold;
gap: 0.5em;
grid-area: name;
.instance {
background-color: var(--prebgcolor);
border-radius: 9999px;
color: var(--accentcolortext);
font-size: smaller;
font-weight: normal;
padding: 0.25em 0.75em;
&:hover {
opacity: 0.8;
text-decoration: none;
}
&.op {
background-color: var(--prebgcolor);
color: var(--accentcolortext);
&::before {
content: "✓";
font-weight: bold;
margin-inline-end: 0.25em;
margin-inline-start: -0.25em;
}
}
}
}
time {
@extend small;
grid-area: time;
line-height: 1.5rem;
}
main {
grid-area: post;
p:first-child {
margin-top: 0.25em;
}
p:last-child {
margin-bottom: 0;
}
}
footer {
margin-top: 0;
@extend small;
grid-area: interactions;
.faves {
color: inherit;
&:hover {
opacity: 0.8;
text-decoration: none;
}
&::before {
color: red;
content: "♥";
font-size: 1.25rem;
margin-inline-end: 0.25em;
}
}
}
.emoji {
display: inline;
height: 1.25em;
vertical-align: middle;
width: 1.25em;
}
.invisible {
display: none;
}
.ellipsis::after {
content: "…";
}
}
}
Parameters added to TOML frontmatter
[comments]
host = "fedi.lukejohns.online"
username = "luke"
id = 113640877367575241
bsky_username = "lukejohns.online"
bsky_id = "3ld4q2gagg5f2"
Comments
You can use your Mastodon or other Fediverse account to comment on this post by replying to this thread.
Alternatively, you can reply to this Bluesky thread using a bridged Bluesky account.
Learn how this is implemented here.