Files
indiekit-blog/js/comments.js
svemagie a166af2306 chore: sync upstream — performance, webmentions v2, OG v3
- _data: switch to cachedFetch wrapper (10s timeout + 4h watch cache)
- js/webmentions.js: owner reply threading, platform provenance badges, DOM dedup, Micropub reply support
- js/comments.js: owner detection, reply system, Alpine.store integration
- _includes/components/webmentions.njk: data-wm-* attrs, provenance badge slots, reply buttons
- _includes/components/comments.njk: owner-aware comment form, threaded replies
- widgets/toc.njk: Alpine.js tocScanner upgrade (replaces is-land/inline-JS)
- lib/og.js + og-cli.js: OG card v3 (light theme, avatar, batched spawn, DESIGN_VERSION=3)
- eleventy.config.js: hasOgImage cache, memoized date filters, batched OG/unfurl, post-build GC, YouTube check opt
- base.njk: Inter font preloads + toc-scanner.js script
- critical.css: font-face declarations (font-display:optional)
- tailwind.css: font-display swap→optional
- tailwind.config.js: prose link colors -700→-600
- Color design system: accent-700/300 → accent-600/400 across components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:56:56 +01:00

292 lines
8.3 KiB
JavaScript

/**
* Client-side comments component (Alpine.js)
* Handles IndieAuth flow, comment submission, display, and owner detection
*
* Registered via Alpine.data() so the component is available
* regardless of script loading order.
*/
document.addEventListener("alpine:init", () => {
// Global owner state store — shared across components
Alpine.store("owner", {
isOwner: false,
profile: null,
syndicationTargets: {},
});
Alpine.data("commentsSection", (targetUrl) => ({
targetUrl,
user: null,
meUrl: "",
commentText: "",
comments: [],
loading: true,
authLoading: false,
submitting: false,
statusMessage: "",
statusType: "info",
maxLength: 2000,
showForm: false,
isOwner: false,
ownerProfile: null,
syndicationTargets: {},
replyingTo: null,
replyText: "",
replySubmitting: false,
async init() {
await this.checkSession();
await this.checkOwner();
await this.loadComments();
if (this.isOwner) {
// Notify webmentions.js that owner is detected (for reply buttons)
document.dispatchEvent(new CustomEvent("owner:detected"));
}
this.handleAuthReturn();
},
async checkSession() {
try {
const res = await fetch("/comments/api/session", {
credentials: "include",
});
if (res.ok) {
const data = await res.json();
if (data.user) this.user = data.user;
}
} catch {
// No session
}
},
async checkOwner() {
try {
const res = await fetch("/comments/api/is-owner", {
credentials: "include",
});
if (res.ok) {
const data = await res.json();
if (data.isOwner) {
this.isOwner = true;
this.ownerProfile = {
name: data.name,
url: data.url,
photo: data.photo,
};
this.syndicationTargets = data.syndicationTargets || {};
// Also update global store for webmentions component
Alpine.store("owner").isOwner = true;
Alpine.store("owner").profile = this.ownerProfile;
Alpine.store("owner").syndicationTargets = this.syndicationTargets;
// Note: owner:detected event is dispatched from init() after
// this completes, so the Alpine store is populated before the event fires
}
}
} catch {
// Not owner
}
},
startReply(commentId, platform, replyUrl, syndicateTo) {
this.replyingTo = { commentId, platform, replyUrl, syndicateTo };
this.replyText = "";
},
cancelReply() {
this.replyingTo = null;
this.replyText = "";
},
async submitReply() {
if (!this.replyText.trim() || !this.replyingTo) return;
this.replySubmitting = true;
try {
if (this.replyingTo.platform === "comment") {
// Native comment reply — POST to comments API
const res = await fetch("/comments/api/reply", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
parent_id: this.replyingTo.commentId,
content: this.replyText,
target: this.targetUrl,
}),
});
if (res.ok) {
const data = await res.json();
if (data.comment) {
this.comments.push(data.comment);
}
this.showStatus("Reply posted!", "success");
} else {
const data = await res.json();
this.showStatus(data.error || "Failed to reply", "error");
}
} else {
// Micropub reply — POST to /micropub
const micropubBody = {
type: ["h-entry"],
properties: {
content: [this.replyText],
"in-reply-to": [this.replyingTo.replyUrl],
},
};
// Only add syndication target for the matching platform
if (this.replyingTo.syndicateTo) {
micropubBody.properties["mp-syndicate-to"] = [
this.replyingTo.syndicateTo,
];
} else {
// IndieWeb webmention — no syndication, empty array
micropubBody.properties["mp-syndicate-to"] = [];
}
const res = await fetch("/micropub", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "include",
body: JSON.stringify(micropubBody),
});
if (res.ok || res.status === 201 || res.status === 202) {
this.showStatus("Reply posted and syndicated!", "success");
} else {
const data = await res.json().catch(() => ({}));
this.showStatus(
data.error_description || data.error || "Failed to post reply",
"error",
);
}
}
this.replyingTo = null;
this.replyText = "";
} catch (error) {
this.showStatus("Error posting reply: " + error.message, "error");
} finally {
this.replySubmitting = false;
}
},
handleAuthReturn() {
const params = new URLSearchParams(window.location.search);
const authError = params.get("auth_error");
if (authError) {
this.showStatus(`Authentication failed: ${authError}`, "error");
window.history.replaceState(
{},
"",
window.location.pathname + "#comments",
);
}
},
async loadComments() {
this.loading = true;
try {
const url = `/comments/api/comments?target=${encodeURIComponent(this.targetUrl)}`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
this.comments = data.children || [];
// Auto-expand if comments exist
if (this.comments.length > 0) {
this.showForm = true;
}
}
} catch (e) {
console.error("[Comments] Load error:", e);
} finally {
this.loading = false;
}
},
async startAuth() {
this.authLoading = true;
try {
const res = await fetch("/comments/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
me: this.meUrl,
returnUrl: window.location.pathname + "#comments",
}),
credentials: "include",
});
if (!res.ok) {
const data = await res.json();
this.showStatus(data.error || "Auth failed", "error");
return;
}
const data = await res.json();
if (data.authUrl) {
window.location.href = data.authUrl;
}
} catch {
this.showStatus("Failed to start authentication", "error");
} finally {
this.authLoading = false;
}
},
async submitComment() {
if (!this.commentText.trim()) return;
this.submitting = true;
try {
const res = await fetch("/comments/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: this.commentText,
target: this.targetUrl,
}),
credentials: "include",
});
if (res.ok) {
const data = await res.json();
if (data.comment) {
this.comments.unshift(data.comment);
}
this.commentText = "";
this.showStatus("Comment posted!", "success");
} else {
const data = await res.json();
this.showStatus(data.error || "Failed to post", "error");
}
} catch {
this.showStatus("Error posting comment", "error");
} finally {
this.submitting = false;
}
},
signOut() {
document.cookie =
"comment_session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
this.user = null;
this.showStatus("Signed out", "info");
},
showStatus(message, type = "info") {
this.statusMessage = message;
this.statusType = type;
setTimeout(() => {
this.statusMessage = "";
}, 5000);
},
}));
});