Files
lit.ruv.wtf/website/scripts/blogPage.js

243 lines
7.0 KiB
JavaScript

import { MarkdownRenderer } from "./MarkdownRenderer.js";
import { BlogComments } from "./BlogComments.js";
/**
* Decodes markdown payload from a JSON script element.
*
* @param {HTMLScriptElement | null} payloadEl
* @returns {string}
*/
function readMarkdownPayload(payloadEl) {
if (!payloadEl) return "";
try {
const parsed = JSON.parse(payloadEl.textContent || "\"\"");
return typeof parsed === "string" ? parsed : "";
} catch (error) {
console.error("Failed to parse blog markdown payload", error);
return "";
}
}
/**
* Wires copy buttons rendered by MarkdownRenderer.
*
* @param {ParentNode} container
* @returns {void}
*/
function wireCopyButtons(container) {
container.querySelectorAll(".md-copy-btn").forEach((button) => {
button.addEventListener("click", (event) => {
event.stopPropagation();
const code = button.getAttribute("data-code");
if (!code) return;
navigator.clipboard.writeText(code).then(() => {
const originalText = button.textContent;
button.textContent = "✓";
button.classList.add("copied");
setTimeout(() => {
button.textContent = originalText;
button.classList.remove("copied");
}, 2000);
}).catch((error) => {
console.error("Failed to copy code", error);
});
});
});
}
/**
* Renders blog markdown on static post pages using the shared renderer.
*
* @returns {void}
*/
function renderStaticBlogPost() {
/** @type {HTMLElement | null} */
const target = document.querySelector("[data-blog-post-content]");
if (!target) return;
/** @type {HTMLScriptElement | null} */
const payload = document.querySelector("#blogPostMarkdown");
const markdown = readMarkdownPayload(payload);
target.innerHTML = MarkdownRenderer.render(markdown);
MarkdownRenderer.applyImageScale(target);
wireCopyButtons(target);
}
/**
* Mounts Matrix-backed comments/reactions for the current blog post.
*
* @returns {Promise<void>}
*/
async function setupBlogComments() {
/** @type {HTMLElement | null} */
const mount = document.querySelector("[data-blog-comments]");
if (!mount) return;
const slug = window.location.pathname
.split("/")
.filter(Boolean)
.pop() || "unknown";
const homeserver = mount.dataset.matrixHomeserver || "https://chat.ruv.wtf";
const roomAlias = mount.dataset.matrixRoom || "#general:chat.ruv.wtf";
const comments = new BlogComments({
homeserver,
roomAlias,
postSlug: slug
});
await comments.initialize(mount);
}
/**
* Toggles the compact top bar when the hero logo scrolls out of view.
*
* @returns {void}
*/
function setupScrollTopBar() {
const body = document.body;
const bar = document.querySelector("[data-blog-scroll-topbar]");
if (!bar) return;
let lastScrollTop = 0;
const applyState = () => {
const scrollTop = body.scrollTop;
const scrollingDown = scrollTop > lastScrollTop;
lastScrollTop = scrollTop;
if (scrollTop < 80) {
body.classList.remove("blog-scrolled");
} else if (scrollingDown) {
body.classList.remove("blog-scrolled");
} else {
body.classList.add("blog-scrolled");
}
};
body.addEventListener("scroll", applyState, { passive: true });
applyState();
}
/**
* Draws SVG bezier splines from the peek-card exec pins to the main blog card.
*
* @returns {void}
*/
function setupPeekSplines() {
const svg = document.querySelector(".blog-post-splines");
const layout = document.querySelector(".blog-post-layout");
const card = document.querySelector(".blog-card");
if (!svg || !layout || !card) return;
const WIRE_COLOR = "#ffffff";
const WIRE_OPACITY = "0.35";
/**
* Creates an SVG path element styled as an exec wire.
*
* @returns {SVGPathElement}
*/
function makePath() {
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
p.setAttribute("fill", "none");
p.setAttribute("stroke", WIRE_COLOR);
p.setAttribute("stroke-width", "2.5");
p.setAttribute("stroke-linecap", "round");
p.style.opacity = WIRE_OPACITY;
return p;
}
/**
* Returns the center of an element relative to the layout container.
*
* @param {Element} el
* @returns {{x: number, y: number}}
*/
function centerOf(el) {
const er = el.getBoundingClientRect();
const lr = layout.getBoundingClientRect();
return {
x: er.left - lr.left + er.width / 2,
y: er.top - lr.top + er.height / 2,
};
}
/**
* Returns the midpoint of a card's left or right edge relative to the layout.
*
* @param {Element} el
* @param {'left'|'right'} side
* @returns {{x: number, y: number}}
*/
function edgeOf(el, side) {
const er = el.getBoundingClientRect();
const lr = layout.getBoundingClientRect();
return {
x: side === 'left' ? er.left - lr.left : er.right - lr.left,
y: er.top - lr.top + 158,
};
}
const pathPrev = makePath();
const pathNext = makePath();
svg.appendChild(pathPrev);
svg.appendChild(pathNext);
/** @returns {void} */
function draw() {
const lr = layout.getBoundingClientRect();
svg.setAttribute("width", String(lr.width));
svg.setAttribute("height", String(lr.height));
const prevPin = document.querySelector("[data-peek-pin='prev-out']");
const nextPin = document.querySelector("[data-peek-pin='next-in']");
const cardLeft = edgeOf(card, "left");
const cardRight = edgeOf(card, "right");
if (prevPin) {
const s = centerOf(prevPin);
const e = cardLeft;
const cp = Math.max(60, Math.abs(e.x - s.x) * 0.5);
pathPrev.setAttribute("d", `M ${s.x} ${s.y} C ${s.x + cp} ${s.y} ${e.x - cp} ${e.y} ${e.x} ${e.y}`);
} else {
pathPrev.setAttribute("d", "");
}
if (nextPin) {
const s = cardRight;
const e = centerOf(nextPin);
const cp = Math.max(60, Math.abs(e.x - s.x) * 0.5);
pathNext.setAttribute("d", `M ${s.x} ${s.y} C ${s.x + cp} ${s.y} ${e.x - cp} ${e.y} ${e.x} ${e.y}`);
} else {
pathNext.setAttribute("d", "");
}
}
draw();
window.addEventListener("resize", draw, { passive: true });
window.addEventListener("scroll", draw, { passive: true });
}
/**
* Bootstraps static blog page behaviors.
*
* @returns {void}
*/
async function initializeBlogPage() {
renderStaticBlogPost();
setupScrollTopBar();
setupPeekSplines();
await setupBlogComments();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeBlogPage, { once: true });
} else {
initializeBlogPage();
}