aboutsummaryrefslogtreecommitdiff
path: root/static/js
diff options
context:
space:
mode:
authorJakob L. Kreuze2024-07-13 18:04:05 -0400
committerJakob L. Kreuze2024-07-13 18:11:42 -0400
commit81c4d517735983a5afd6e9dc800257c761598527 (patch)
tree6b924707914d385126e6d330d2c628fd26f23a27 /static/js
parent7f37518e4792f040a753a5d8d68d51e76cc0b2be (diff)
The `org' directory is no longer necessary
Diffstat (limited to 'static/js')
-rw-r--r--static/js/comment-reaction.js143
-rw-r--r--static/js/dynamic-comment-form.js94
-rw-r--r--static/js/oneko.js182
-rw-r--r--static/js/proof-of-work.js109
-rw-r--r--static/js/rsvp.js206
-rw-r--r--static/js/section-folds.js66
-rw-r--r--static/js/webmention.js229
7 files changed, 1029 insertions, 0 deletions
diff --git a/static/js/comment-reaction.js b/static/js/comment-reaction.js
new file mode 100644
index 0000000..1002fa2
--- /dev/null
+++ b/static/js/comment-reaction.js
@@ -0,0 +1,143 @@
+/*
+ * comment-reaction.js -- Simple emoji picker and glue for reaction endpoint
+ * Copyright © 2023 Jakob L. Kreuze <zerodaysfordays@sdf.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ */
+
+function encodeAsFormData(obj) {
+ // Turn the data object into an array of URL-encoded key/value pairs.
+ let urlEncodedDataPairs = [];
+ for (let key in obj) {
+ urlEncodedDataPairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]));
+ }
+ return urlEncodedDataPairs.join('&');
+}
+
+function postReaction(reaction, callback) {
+ let xhr = new XMLHttpRequest();
+ xhr.open('POST', "/api/comment/react", true);
+
+ // Endpoint expects HTML form data.
+ xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
+
+ xhr.onload = function(data) {
+ switch (xhr.status) {
+ case 200:
+ callback();
+ break;
+ case 429:
+ alert("Failed -- you can react at most once an hour.");
+ break;
+ default:
+ let resp = JSON.parse(xhr.response);
+ alert("Failed -- " + resp.error);
+ break;
+ }
+ }
+ xhr.onerror = function(error) {
+ throw new Error(`Request failed: ${error}`);
+ }
+
+ xhr.send(encodeAsFormData(reaction));
+}
+
+function selectorWidgetName(id) {
+ return `emoji-picker-${id}`;
+}
+
+// Simple Emoji picker widget.
+function selectorWidget(id) {
+ function emojiButton(codepoint) {
+ let span = document.createElement("span");
+ span.innerHTML = String.fromCodePoint(codepoint);
+ return span;
+ }
+
+ let selector = document.createElement("div");
+ selector.setAttribute("id", selectorWidgetName(id));
+ selector.setAttribute("class", "emoji-picker");
+
+ for (let i = 0x1F600; i < 0x1F64F; i++) {
+ selector.appendChild(emojiButton(i));
+ }
+ selector.appendChild(emojiButton(0x1F37B));
+ selector.appendChild(emojiButton(0x1F440));
+ selector.appendChild(emojiButton(0x1F446));
+ selector.appendChild(emojiButton(0x1F447));
+ selector.appendChild(emojiButton(0x1F448));
+ selector.appendChild(emojiButton(0x1F449));
+ selector.appendChild(emojiButton(0x1F450));
+ selector.appendChild(emojiButton(0x270A));
+ selector.appendChild(emojiButton(0x270B));
+ selector.appendChild(emojiButton(0x270C));
+ selector.appendChild(emojiButton(0x270D));
+ for (let i = 0x1F90C; i < 0x1F9FF; i++) {
+ selector.appendChild(emojiButton(i));
+ }
+ return selector;
+}
+
+// Add a "react" button to every local comment.
+window.addEventListener("load", () => {
+ // First pass to add a separator for the buttons.
+ let replyButtons = document.querySelectorAll(".comment-reply-button");
+ for (let button of replyButtons) {
+ let parent = button.parentNode;
+ parent.innerHTML += " - ";
+
+ }
+
+ // Second pass to add the actual "react" button.
+ replyButtons = document.querySelectorAll(".comment-reply-button");
+ for (let button of replyButtons) {
+ let id = button.getAttribute("data-reply-to-id");
+ let reactButton = document.createElement("a");
+ reactButton.setAttribute("class", "comment-react-button");
+ reactButton.setAttribute("href", "#");
+ reactButton.setAttribute("data-react-to-id", id);
+ reactButton.innerHTML = "react";
+ button.parentNode.appendChild(reactButton);
+
+ reactButton.addEventListener('click', (e) => {
+ // If we already made a selector widget for this comment, let's "hide" the
+ // one that exists.
+ let existingSelectorWidget = document.getElementById(selectorWidgetName(id));
+ if (existingSelectorWidget !== null) {
+ existingSelectorWidget.remove();
+ e.preventDefault();
+ return;
+ }
+
+ let emojiPicker = selectorWidget(id);
+ button.parentNode.parentNode.insertBefore(emojiPicker, button.parentNode);
+ for (let button of emojiPicker.children) {
+ button.addEventListener('click', (e) => {
+ postReaction({
+ "id": reactButton.getAttribute("data-react-to-id"),
+ "reaction": button.innerHTML
+ }, () => {
+ let reactAcknowledgement = document.createElement("span");
+ reactAcknowledgement.innerHTML = "successfully reacted!"
+ reactButton.replaceWith(reactAcknowledgement);
+ });
+ emojiPicker.remove();
+ });
+ }
+
+ e.preventDefault();
+ });
+ }
+});
diff --git a/static/js/dynamic-comment-form.js b/static/js/dynamic-comment-form.js
new file mode 100644
index 0000000..4be2d52
--- /dev/null
+++ b/static/js/dynamic-comment-form.js
@@ -0,0 +1,94 @@
+/*
+ * dynamic-comment-form.js -- Captcha challenge loader
+ * Copyright © 2022 - 2023 Jakob L. Kreuze <zerodaysfordays@sdf.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ */
+
+function makeRequest (method, url) {
+ return new Promise(function (resolve, reject) {
+ var xhr = new XMLHttpRequest();
+ xhr.open(method, url);
+ xhr.onload = function () {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve(xhr.response);
+ } else {
+ reject({
+ status: xhr.status,
+ statusText: xhr.statusText
+ });
+ }
+ };
+ xhr.onerror = function () {
+ reject({
+ status: xhr.status,
+ statusText: xhr.statusText
+ });
+ };
+ xhr.send();
+ });
+}
+
+window.addEventListener("load", () => {
+ let primaryDisplay = document.getElementById("comment-form-primary");
+ primaryDisplay.removeAttribute("hidden");
+
+ let altDisplay = document.getElementById("comment-form-alt");
+ altDisplay.remove();
+
+ let trigger = document.getElementById("captcha-challenge-trigger");
+ let triggerBlock = document.getElementById("captcha-trigger-block");
+ trigger.addEventListener("click", (event) => {
+ let captchaField = document.getElementById("comment-captcha");
+
+ let captchaId = document.getElementById("captcha-id");
+ let captchaImage = document.getElementById("captcha-image");
+
+ trigger.innerHTML = "Please wait...";
+ trigger.disabled = true;
+
+ if (captchaId.value === "") {
+ makeRequest("GET", "/api/challenge/captcha")
+ .then(function (data) {
+ // Update form with parsed values.
+ let challengeData = JSON.parse(data);
+ captchaId.value = challengeData["challenge-id"];
+ captchaImage.src = challengeData["image"];
+
+ // Unhide fieldset.
+ captchaField.removeAttribute("hidden");
+
+ // Hide initial trigger.
+ triggerBlock.remove();
+ })
+ .catch(console.err);
+ }
+
+ event.preventDefault();
+ });
+});
+
+
+window.addEventListener("load", () => {
+ let header = document.getElementById("comment-form-header");
+ let replyTo = document.getElementById("reply-to");
+ let buttons = document.querySelectorAll(".comment-reply-button");
+ buttons.forEach((button) => {
+ button.addEventListener("click", (event) => {
+ header.innerHTML = "Comment form (reply mode)";
+ replyTo.value = button.getAttribute("data-reply-to-id");
+ });
+ });
+});
diff --git a/static/js/oneko.js b/static/js/oneko.js
new file mode 100644
index 0000000..b0ba164
--- /dev/null
+++ b/static/js/oneko.js
@@ -0,0 +1,182 @@
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the “Software”), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+(function oneko() {
+ const nekoEl = document.createElement("div");
+ let nekoPosX = 32;
+ let nekoPosY = 32;
+ let mousePosX = 0;
+ let mousePosY = 0;
+ let frameCount = 0;
+ let idleTime = 0;
+ let idleAnimation = null;
+ let idleAnimationFrame = 0;
+ const nekoSpeed = 10;
+ const spriteSets = {
+ idle: [[-3, -3]],
+ alert: [[-7, -3]],
+ scratch: [
+ [-5, 0],
+ [-6, 0],
+ [-7, 0],
+ ],
+ tired: [[-3, -2]],
+ sleeping: [
+ [-2, 0],
+ [-2, -1],
+ ],
+ N: [
+ [-1, -2],
+ [-1, -3],
+ ],
+ NE: [
+ [0, -2],
+ [0, -3],
+ ],
+ E: [
+ [-3, 0],
+ [-3, -1],
+ ],
+ SE: [
+ [-5, -1],
+ [-5, -2],
+ ],
+ S: [
+ [-6, -3],
+ [-7, -2],
+ ],
+ SW: [
+ [-5, -3],
+ [-6, -1],
+ ],
+ W: [
+ [-4, -2],
+ [-4, -3],
+ ],
+ NW: [
+ [-1, 0],
+ [-1, -1],
+ ],
+ };
+ function create() {
+ nekoEl.id = "oneko";
+ nekoEl.style.width = "32px";
+ nekoEl.style.height = "32px";
+ nekoEl.style.position = "fixed";
+ nekoEl.style.backgroundImage = "url('/static/image/oneko.gif')";
+ nekoEl.style.imageRendering = "pixelated";
+ nekoEl.style.left = "16px";
+ nekoEl.style.top = "16px";
+
+ document.body.appendChild(nekoEl);
+
+ document.onmousemove = (event) => {
+ mousePosX = event.clientX;
+ mousePosY = event.clientY;
+ };
+
+ window.onekoInterval = setInterval(frame, 100);
+ }
+
+ function setSprite(name, frame) {
+ const sprite = spriteSets[name][frame % spriteSets[name].length];
+ nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${
+ sprite[1] * 32
+ }px`;
+ }
+
+ function resetIdleAnimation() {
+ idleAnimation = null;
+ idleAnimationFrame = 0;
+ }
+
+ function idle() {
+ idleTime += 1;
+
+ // every ~ 20 seconds
+ if (
+ idleTime > 10 &&
+ Math.floor(Math.random() * 200) == 0 &&
+ idleAnimation == null
+ ) {
+ idleAnimation = ["sleeping", "scratch"][
+ Math.floor(Math.random() * 2)
+ ];
+ }
+
+ switch (idleAnimation) {
+ case "sleeping":
+ if (idleAnimationFrame < 8) {
+ setSprite("tired", 0);
+ break;
+ }
+ setSprite("sleeping", Math.floor(idleAnimationFrame / 4));
+ if (idleAnimationFrame > 192) {
+ resetIdleAnimation();
+ }
+ break;
+ case "scratch":
+ setSprite("scratch", idleAnimationFrame);
+ if (idleAnimationFrame > 9) {
+ resetIdleAnimation();
+ }
+ break;
+ default:
+ setSprite("idle", 0);
+ return;
+ }
+ idleAnimationFrame += 1;
+ }
+
+ function frame() {
+ frameCount += 1;
+ const diffX = nekoPosX - mousePosX;
+ const diffY = nekoPosY - mousePosY;
+ const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
+
+ if (distance < nekoSpeed || distance < 48) {
+ idle();
+ return;
+ }
+
+ idleAnimation = null;
+ idleAnimationFrame = 0;
+
+ if (idleTime > 1) {
+ setSprite("alert", 0);
+ // count down after being alerted before moving
+ idleTime = Math.min(idleTime, 7);
+ idleTime -= 1;
+ return;
+ }
+
+ direction = diffY / distance > 0.5 ? "N" : "";
+ direction += diffY / distance < -0.5 ? "S" : "";
+ direction += diffX / distance > 0.5 ? "W" : "";
+ direction += diffX / distance < -0.5 ? "E" : "";
+ setSprite(direction, frameCount);
+
+ nekoPosX -= (diffX / distance) * nekoSpeed;
+ nekoPosY -= (diffY / distance) * nekoSpeed;
+
+ nekoEl.style.left = `${nekoPosX - 16}px`;
+ nekoEl.style.top = `${nekoPosY - 16}px`;
+ }
+
+ create();
+})();
diff --git a/static/js/proof-of-work.js b/static/js/proof-of-work.js
new file mode 100644
index 0000000..b4b5c57
--- /dev/null
+++ b/static/js/proof-of-work.js
@@ -0,0 +1,109 @@
+/*
+ * proof-of-work.js -- Alternative captcha based on SHA-256 proof-of-work.
+ * Copyright © 2022 - 2023 Jakob L. Kreuze <zerodaysfordays@sdf.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ */
+
+function makeid(length) {
+ let result = '';
+ let alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+ for (let i = 0; i < length; i++) {
+ result += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
+ }
+ return result;
+}
+
+async function digestMessage(message) {
+ const msgUtf8 = new TextEncoder().encode(message);
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgUtf8);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
+ return hashHex;
+}
+
+async function findPrefix(hardness, nonce) {
+ while (true) {
+ let prefix = makeid(32);
+ let digestHex = await digestMessage(prefix + nonce);
+ if (digestHex.startsWith("0".repeat(hardness))) {
+ return prefix;
+ }
+ }
+}
+
+function makeRequest (method, url) {
+ return new Promise(function (resolve, reject) {
+ var xhr = new XMLHttpRequest();
+ xhr.open(method, url);
+ xhr.onload = function () {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve(xhr.response);
+ } else {
+ reject({
+ status: xhr.status,
+ statusText: xhr.statusText
+ });
+ }
+ };
+ xhr.onerror = function () {
+ reject({
+ status: xhr.status,
+ statusText: xhr.statusText
+ });
+ };
+ xhr.send();
+ });
+}
+
+function raceEndpoint() {
+ return new Promise(function (resolve, reject) {
+ makeRequest("GET", "/api/challenge/proof-of-work")
+ .then(function (data) {
+ let challengeData = JSON.parse(data);
+ findPrefix(challengeData.hardness, challengeData.nonce)
+ .then((prefix) => { resolve([prefix, challengeData["challenge-id"]]) } );
+ })
+ .catch(reject);
+ });
+}
+
+window.addEventListener("load", () => {
+ let trigger = document.getElementById("pow-trigger");
+ trigger.removeAttribute("hidden");
+ trigger.addEventListener("click", (e) => {
+ trigger.innerHTML = "Please wait...";
+ trigger.disabled = true;
+ raceEndpoint()
+ .then((result) => {
+ let resultField = document.getElementById("captcha-alt");
+ let challengeIdField = document.getElementById("captcha-alt-id");
+ resultField.value = result[0];
+ challengeIdField.value = result[1];
+ trigger.innerHTML = "Proof-of-Work completed successfully!";
+ trigger.disabled = true;
+
+ // Hide the primary captcha, too, to make it clear that it isn't necessary to complete.
+ let primaryChallenge = document.getElementById("captcha-challenge-primary");
+ primaryChallenge.hidden = true;
+ })
+ .catch((err) => {
+ console.log(err);
+ trigger.innerHTML = "Proof-of-Work failed!";
+ trigger.disabled = true;
+ });
+ e.preventDefault();
+ })
+});
diff --git a/static/js/rsvp.js b/static/js/rsvp.js
new file mode 100644
index 0000000..06cdb66
--- /dev/null
+++ b/static/js/rsvp.js
@@ -0,0 +1,206 @@
+/*
+ * rsvp.js -- Simple web-based RSVP tool.
+ * Copyright © 2022 - 2023 Jakob L. Kreuze <zerodaysfordays@sdf.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ */
+
+// Common nodes.
+const guestView = document.getElementById("guest-view");
+const addEntry = document.getElementById("add-entry");
+const removeEntry = document.getElementById("remove-entry");
+const submitButton = document.getElementById("submit-form");
+const form = document.getElementById("rsvp-input");
+
+// Parse out the HTML form into something we can send to the backend.
+function submitForm() {
+ let data = new FormData(form);
+ if (data.get("name").trim().length == 0) {
+ alert("You have to provide something for \"Name\".");
+ return;
+ } else if (data.get("rsvp") === null) {
+ alert("You have to choose an option for \"RSVP Status\".");
+ return;
+ }
+
+ let res = { guests: [] };
+ if (urlParams.get("i")) {
+ res["id"] = urlParams.get("i");
+ } else if (urlParams.get("r")) {
+ res["update"] = urlParams.get("r");
+ }
+ for (let pair of data.entries()) {
+ if (pair[0].startsWith("guest-")) {
+ res.guests.push(pair[1]);
+ } else if (pair[0] === "rsvp") {
+ const radioButtons = document.querySelectorAll('input[name="rsvp"]');
+ for (const radioButton of radioButtons) {
+ if (radioButton.checked) {
+ res.rsvp = radioButton.id;
+ break;
+ }
+ }
+ } else {
+ res[pair[0]] = pair[1];
+ }
+ }
+ res.guests = res.guests.join(",");
+
+ let xhr = new XMLHttpRequest();
+ xhr.open("POST", `${location.protocol}//${location.host}/api/rsvp`);
+ xhr.send(JSON.stringify(res));
+ xhr.onload = function(data) {
+ if (xhr.status != 200) {
+ alert("Failed to submit form.");
+ } else {
+ document.getElementById("rsvp").innerHTML = "";
+ let resp = JSON.parse(xhr.response);
+ let elem = document.createElement("p");
+ elem.innerHTML = "Thanks for registering! Please bookmark or save the following link:";
+ document.getElementById("rsvp").appendChild(elem);
+
+ elem = document.createElement("a");
+ elem.href = [location.protocol, '//', location.host, location.pathname].join('') + `?r=${resp.receipt}`;
+ elem.textContent = elem.href;
+ document.getElementById("rsvp").appendChild(elem);
+
+ elem = document.createElement("p");
+ elem.innerHTML = "This will enable you to edit your RSVP later.";
+ document.getElementById("rsvp").appendChild(elem);
+ }
+ }
+ xhr.onerror = function(error) {
+ throw new Error(`Request failed: ${error}`);
+ }
+}
+
+// Immediately send off an XHR to load info from the backend.
+const urlParams = new URLSearchParams(window.location.search);
+if (!urlParams.get("i") && !urlParams.get("r")) {
+ document.getElementById("loading").remove();
+ let elem = document.createElement("p");
+ elem.innerHTML = "No invitation code provided.";
+ document.getElementById("rsvp").appendChild(elem);
+} else {
+ let xhr = new XMLHttpRequest();
+ if (urlParams.get("i")) {
+ xhr.open("GET", `${location.protocol}//${location.host}/api/rsvp/event-info?i=${urlParams.get("i")}`);
+ } else {
+ xhr.open("GET", `${location.protocol}//${location.host}/api/rsvp/event-info?r=${urlParams.get("r")}`);
+ }
+ xhr.send();
+ xhr.onload = function(data) {
+ document.getElementById("loading").remove();
+ if (xhr.status != 200) {
+ let elem = document.createElement("p");
+ elem.innerHTML = "Invalid invitation code.";
+ document.getElementById("rsvp").appendChild(elem);
+ } else {
+ document.getElementById("rsvp-input").hidden = false;
+
+ let response = JSON.parse(xhr.response);
+
+ let elem = document.createElement("h1");
+ elem.innerHTML = response.title;
+ document.getElementById("event-info").appendChild(elem);
+
+ if (response.image !== "#f") {
+ elem = document.createElement("img");
+ elem.src = `data:image/png;base64,${response.image}`;
+ elem.style = "float: right; max-size: 200px; margin: 16px;";
+ document.getElementById("event-info").appendChild(elem);
+ }
+
+ elem = document.createElement("p");
+ elem.innerHTML = `Where: ${response.location}`;
+ document.getElementById("event-info").appendChild(elem);
+
+ elem = document.createElement("p");
+ elem.innerHTML = `When: ${response.date}`;
+ document.getElementById("event-info").appendChild(elem);
+
+ elem = document.createElement("p");
+ elem.innerHTML = response.description;
+ document.getElementById("event-info").appendChild(elem);
+
+ if (response.hasOwnProperty("rsvps")) {
+ elem = document.createElement("h2");
+ elem.innerHTML = "Current RSVPs";
+ document.getElementById("rsvp-global").appendChild(elem);
+
+ elem = document.createElement("table");
+ document.getElementById("rsvp-global").appendChild(elem);
+
+ for (let rsvp of response.rsvps) {
+ let container = document.createElement("tr");
+ let field = document.createElement("td");
+ field.textContent = rsvp.name;
+ container.append(field);
+ field = document.createElement("td");
+ field.textContent = rsvp.email;
+ container.append(field);
+ field = document.createElement("td");
+ field.textContent = rsvp.guests.replaceAll(',', ', ');
+ container.append(field);
+ field = document.createElement("td");
+ field.textContent = rsvp.attending;
+ container.append(field);
+ elem.appendChild(container);
+ }
+ }
+
+ // Pre-filled info if this is an edit.
+ if (response.hasOwnProperty("name")) {
+ document.getElementById("name").value = response.name;
+ }
+ if (response.hasOwnProperty("email")) {
+ document.getElementById("email").value = response.email;
+ }
+ if (response.hasOwnProperty("attending")) {
+ document.getElementById(response.attending).checked = true;
+ }
+ if (response.hasOwnProperty("guests") && response.guests.trim() != "") {
+ let i = 0;
+ for (let guest of response.guests.split(",")) {
+ let elem = document.createElement("input");
+ elem.type = "text";
+ elem.name = `guest-${i}`;
+ elem.value = guest;
+ guestView.appendChild(elem);
+ i++;
+ }
+ }
+ }
+ }
+ xhr.onerror = function(error) {
+ throw new Error(`Request failed: ${error}`);
+ }
+}
+
+// Attach event listeners to the UI.
+submitButton.addEventListener("click", submitForm);
+submitButton.addEventListener("submit", submitForm);
+addEntry.addEventListener("click", () => {
+ let i = guestView.childNodes.length;
+ let elem = document.createElement("input");
+ elem.type = "text";
+ elem.name = `guest-${i}`;
+ guestView.appendChild(elem);
+});
+removeEntry.addEventListener("click", () => {
+ if (guestView.lastChild) {
+ guestView.lastChild.remove();
+ }
+});
diff --git a/static/js/section-folds.js b/static/js/section-folds.js
new file mode 100644
index 0000000..9440264
--- /dev/null
+++ b/static/js/section-folds.js
@@ -0,0 +1,66 @@
+/*
+ * section-folds.js -- Parse out sections to hide behind an "unfold" dialog
+ * Copyright © 2022 - 2023 Jakob L. Kreuze <zerodaysfordays@sdf.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ */
+
+function handleFolds() {
+ function displayFold(fold, foldText, frontMatterText) {
+ foldText.innerHTML = `⬇️ ${frontMatterText}`;
+ fold.classList.remove("fold-hidden");
+
+ }
+
+ function hideFold(fold, foldText, frontMatterText) {
+ foldText.innerHTML = `➡️ Folded ${frontMatterText}`;
+ fold.classList.add("fold-hidden");
+ }
+
+ let folds = document.querySelectorAll(".section-fold");
+
+ for (let fold of folds) {
+ // The first thing we'll do is generate the "front matter" for the fold, so
+ // that the reader knows what lies beneath the fold.
+ let frontMatterText = `Section on "${fold.dataset.name}"`;
+ let foldFrontMatter = document.createElement("div");
+
+ // The text will be inside of an anchor, which we'll hook onto using an
+ // event listener.
+ let foldAnchor = document.createElement("a");
+ let foldText = document.createElement("p");
+ foldAnchor.appendChild(foldText);
+ fold.parentNode.insertBefore(foldAnchor, fold.nextSibling);
+
+ // The fold will start off in the "hidden" state.
+ let toggle = false;
+ hideFold(fold, foldText, frontMatterText);
+
+ // But clicking on the "front matter" will cause the state of the fold
+ // to toggle, first to "visible", and then back to "hidden".
+ foldAnchor.addEventListener('click', () => {
+ toggle = !toggle;
+ if (toggle) {
+ displayFold(fold, foldText, frontMatterText);
+ } else {
+ hideFold(fold, foldText, frontMatterText);
+ }
+ });
+ }
+
+}
+
+// Defer parsing all of the "folds" until the document has completely loaded.
+document.addEventListener("DOMContentLoaded", handleFolds);
diff --git a/static/js/webmention.js b/static/js/webmention.js
new file mode 100644
index 0000000..d280d4d
--- /dev/null
+++ b/static/js/webmention.js
@@ -0,0 +1,229 @@
+/*
+ * webmention.js -- Fetch and display mentions from webmention.io.
+ * Copyright © 2019 - 2023 Jakob L. Kreuze <zerodaysfordays@sdf.org>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ */
+
+function buildApiUri() {
+ const ref = "jakob.space";
+ const slug = /[^\/]*(?=\.html)/.exec(window.location.href);
+ const endpoints = [
+ `http://${ref}/${slug}.html`,
+ `https://${ref}/${slug}.html`,
+ `http://${ref}/blog/${slug}.html`,
+ `https://${ref}/blog/${slug}.html`,
+ ];
+ return ["https://webmention.io/api/mentions.jf2?per-page=200&page=0"].concat(
+ endpoints.map((endpoint) => {
+ return `target[]=${endpoint}`;
+ }).join("&")
+ ).join("");
+}
+
+function getData(url, callback) {
+ if (fetch) {
+ fetch(url).then(function(response) {
+ if (response.status >= 200 && response.status < 300) {
+ return Promise.resolve(response);
+ } else {
+ return Promise.reject(new Error(`Request failed: ${response.statusText}`));
+ }
+ }).then(function(response) {
+ return response.json();
+ }).then(callback);
+ } else {
+ let xhr = new XMLHttpRequest();
+ xhr.onload = function(data) {
+ callback(JSON.parse(data));
+ }
+ xhr.onerror = function(error) {
+ throw new Error(`Request failed: ${error}`);
+ }
+ }
+}
+
+function makeComment(comment) {
+ const strip = (uri) => {
+ const sep = "://";
+ return uri.substring(uri.indexOf(sep) + sep.length);
+ };
+
+ const element = (type, attributes) => {
+ let res = document.createElement(type);
+ for (const attribute in attributes) {
+ res.setAttribute(attribute, attributes[attribute]);
+ }
+ return res;
+ };
+
+ // Create the h-card section.
+ let avatar = element("img", {
+ "class": "u-photo",
+ "src": comment.url.startsWith("https://lobste.rs/")
+ ? "/static/image/lobsters.png"
+ : comment.author.photo || "/static/image/default-icon.png"
+ });
+
+ let authorName = element("a", {
+ "class": "p-name u-url",
+ "href": comment.author.url
+ });
+ authorName.textContent = comment.author.name;
+
+ let authorURI = element("a", {
+ "class": "author_url",
+ "href": comment.author.url
+ });
+ authorURI.textContent = strip(comment.author.url);
+
+ let hCardContainer = element("div", {
+ "class": "p-author h-card author"
+ });
+ hCardContainer.appendChild(avatar);
+ hCardContainer.appendChild(authorName);
+ hCardContainer.appendChild(authorURI);
+
+ // Create the content section.
+ let contentContainer = element("div", {
+ "class": "e-content p-name comment-content",
+ });
+
+ if (comment.hasOwnProperty("content")) {
+ contentContainer.textContent = comment.content.text;
+ } else {
+ let em = element("em", {});
+ if (comment.hasOwnProperty("wm-property")) {
+ if ("repost-of" === comment["wm-property"]) {
+ em.textContent = "Reposted this!";
+ } else if ("like-of" === comment["wm-property"]) {
+ em.textContent = "Favorited this!";
+ } else {
+ em.textContent = "No text provided :(";
+ }
+ } else {
+ em.textContent = "No text provided :(";
+ }
+ contentContainer.appendChild(em);
+ }
+
+ // Create the metaline section.
+ let pubTime = comment.published || comment["wm-received"];
+ let time = element("time", {
+ "class": "dt-published",
+ "datetime": pubTime,
+ });
+ time.textContent = (new Date(pubTime)).toString();
+
+ let linkBack = element("a", {
+ "class": "u-url",
+ "href": comment.url
+ });
+ linkBack.appendChild(time);
+
+ let metalineContainer = element("div", {
+ "class": "metaline"
+ });
+ metalineContainer.appendChild(linkBack);
+
+ let sourceIcon = document.createElement("img");
+ sourceIcon.classList.add("comment-source-identifier")
+ sourceIcon.src = "/static/image/webmention-logo.png";
+
+ // Put it all together.
+ let wrapper = element("li", {
+ "class": "p-comment h-cite comment comment-source-webmention",
+ });
+ wrapper.appendChild(sourceIcon);
+ wrapper.appendChild(hCardContainer);
+ wrapper.appendChild(contentContainer);
+ wrapper.appendChild(metalineContainer);
+
+ return wrapper;
+}
+
+function makeInteraction(ent) {
+ const strip = (uri) => {
+ const sep = "://";
+ return uri.substring(uri.indexOf(sep) + sep.length);
+ };
+
+ const element = (type, attributes) => {
+ let res = document.createElement(type);
+ for (const attribute in attributes) {
+ res.setAttribute(attribute, attributes[attribute]);
+ }
+ return res;
+ };
+
+ // Create the h-card section.
+ let avatar = element("img", {
+ "class": "u-photo",
+ "src": ent.url.startsWith("https://lobste.rs/")
+ ? "/static/image/lobsters.png"
+ : ent.author.photo || "/static/image/default-icon.png"
+ });
+ let avatarContainer = element("a", {
+ "href": ent.author.url
+ });
+ avatarContainer.appendChild(avatar);
+
+ // Create the content section.
+ let contentContainer = element("div", {
+ "class": "e-content p-name comment-content",
+ });
+ let em = element("em", {});
+ if ("repost-of" === ent["wm-property"]) {
+ em.textContent = "Reposted this!";
+ } else if ("like-of" === ent["wm-property"]) {
+ em.textContent = "Favorited this!";
+ } else {
+ return null;
+ }
+ contentContainer.appendChild(em);
+
+ let sourceIcon = document.createElement("img");
+ sourceIcon.classList.add("comment-source-identifier")
+ sourceIcon.src = "/static/image/webmention-logo.png";
+
+ // Put it all together.
+ let wrapper = element("li", {
+ "class": "p-comment h-cite interaction comment-source-webmention",
+ });
+ wrapper.appendChild(avatarContainer);
+ wrapper.appendChild(contentContainer);
+ wrapper.appendChild(sourceIcon);
+
+ return wrapper;
+}
+
+function makeEntry(ent) {
+ if (ent.hasOwnProperty("wm-property")) {
+ if (ent.hasOwnProperty("content") && ent["wm-property"] !== "repost-of") {
+ return makeComment(ent);
+ } else {
+ return makeInteraction(ent);
+ }
+ }
+ return null;
+}
+
+getData(buildApiUri(), (json) => {
+ json.children
+ .map(makeEntry)
+ .filter((x) => x !== null)
+ .forEach((elem) => {
+ document.getElementById("webmention-container").appendChild(elem);
+ })});

© 2015 - 2026 Jakob L. Kreuze