mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
feat: add comment system components and recent comments widget
- Comment area on post pages (IndieAuth sign-in, submit, display) - Alpine.js client-side component for auth flow and comment CRUD - Recent comments sidebar widget with build-time data fetching - Include comments.js in base layout, comments.njk before webmentions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
24
_data/recentComments.js
Normal file
24
_data/recentComments.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Recent Comments Data
|
||||||
|
* Fetches the 5 most recent comments at build time for the sidebar widget.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import EleventyFetch from "@11ty/eleventy-fetch";
|
||||||
|
|
||||||
|
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
|
||||||
|
|
||||||
|
export default async function () {
|
||||||
|
try {
|
||||||
|
const url = `${INDIEKIT_URL}/comments/api/comments?limit=5`;
|
||||||
|
console.log(`[recentComments] Fetching: ${url}`);
|
||||||
|
const data = await EleventyFetch(url, {
|
||||||
|
duration: "15m",
|
||||||
|
type: "json",
|
||||||
|
});
|
||||||
|
console.log(`[recentComments] Got ${(data.children || []).length} comments`);
|
||||||
|
return data.children || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[recentComments] Unavailable: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
{% include "components/widgets/feedland.njk" %}
|
{% include "components/widgets/feedland.njk" %}
|
||||||
{% elif widget.type == "categories" %}
|
{% elif widget.type == "categories" %}
|
||||||
{% include "components/widgets/categories.njk" %}
|
{% include "components/widgets/categories.njk" %}
|
||||||
|
{% elif widget.type == "recent-comments" %}
|
||||||
|
{% include "components/widgets/recent-comments.njk" %}
|
||||||
{% elif widget.type == "search" %}
|
{% elif widget.type == "search" %}
|
||||||
<div class="sidebar-widget">
|
<div class="sidebar-widget">
|
||||||
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">Search</h3>
|
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">Search</h3>
|
||||||
|
|||||||
94
_includes/components/comments.njk
Normal file
94
_includes/components/comments.njk
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{# Comments section — shown on post pages before webmentions #}
|
||||||
|
{% set absoluteUrl = site.url + page.url %}
|
||||||
|
|
||||||
|
<section class="comments mt-8 pt-8 border-t border-surface-200 dark:border-surface-700" id="comments"
|
||||||
|
x-data="commentsSection('{{ absoluteUrl }}')"
|
||||||
|
x-init="init()">
|
||||||
|
|
||||||
|
<h2 class="text-xl font-bold mb-4">Comments</h2>
|
||||||
|
|
||||||
|
{# Status messages #}
|
||||||
|
<div x-show="statusMessage" x-cloak
|
||||||
|
x-bind:class="statusType === 'error' ? 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400' :
|
||||||
|
statusType === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' :
|
||||||
|
'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400'"
|
||||||
|
class="p-3 rounded-lg mb-4 text-sm">
|
||||||
|
<span x-text="statusMessage"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Sign-in form (shown when not authenticated) #}
|
||||||
|
<div x-show="!user" x-cloak>
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400 mb-3">Sign in with your website to comment:</p>
|
||||||
|
<form x-on:submit.prevent="startAuth()" class="flex gap-2 items-end flex-wrap">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="comment-me" class="block text-sm font-medium mb-1">Your website</label>
|
||||||
|
<input id="comment-me" type="url" x-model="meUrl"
|
||||||
|
placeholder="https://yourdomain.com" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg dark:bg-surface-800 dark:border-surface-600">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button" x-bind:disabled="authLoading">
|
||||||
|
<span x-show="!authLoading">Sign In</span>
|
||||||
|
<span x-show="authLoading" x-cloak>Signing in...</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Comment form (shown when authenticated) #}
|
||||||
|
<div x-show="user" x-cloak>
|
||||||
|
<div class="flex items-center gap-2 mb-3 text-sm text-surface-600 dark:text-surface-400">
|
||||||
|
<span>Signed in as</span>
|
||||||
|
<a x-bind:href="user?.url" class="font-medium hover:underline" x-text="user?.name || user?.url" target="_blank" rel="noopener"></a>
|
||||||
|
<button x-on:click="signOut()" class="text-xs underline hover:no-underline">Sign out</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form x-on:submit.prevent="submitComment()">
|
||||||
|
<textarea x-model="commentText" rows="4" required
|
||||||
|
placeholder="Share your thoughts... (supports **bold**, *italic*, and [links](url))"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg mb-2 dark:bg-surface-800 dark:border-surface-600"
|
||||||
|
x-bind:maxlength="maxLength"></textarea>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-surface-500" x-text="commentText.length + '/' + maxLength"></span>
|
||||||
|
<button type="submit" class="button" x-bind:disabled="submitting">
|
||||||
|
<span x-show="!submitting">Post Comment</span>
|
||||||
|
<span x-show="submitting" x-cloak>Posting...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Comment list #}
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<template x-if="loading">
|
||||||
|
<p class="text-sm text-surface-500">Loading comments...</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-for="comment in comments" x-bind:key="comment.published">
|
||||||
|
<div class="p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<template x-if="comment.author?.photo">
|
||||||
|
<img x-bind:src="comment.author.photo" x-bind:alt="comment.author.name"
|
||||||
|
class="w-8 h-8 rounded-full flex-shrink-0" loading="lazy">
|
||||||
|
</template>
|
||||||
|
<template x-if="!comment.author?.photo">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-surface-200 dark:bg-surface-700 flex-shrink-0 flex items-center justify-center text-xs font-bold"
|
||||||
|
x-text="(comment.author?.name || '?')[0].toUpperCase()">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a x-bind:href="comment.author?.url" class="font-medium text-sm hover:underline" target="_blank" rel="noopener"
|
||||||
|
x-text="comment.author?.name || comment.author?.url"></a>
|
||||||
|
<time class="text-xs text-surface-500" x-bind:datetime="comment.published"
|
||||||
|
x-text="new Date(comment.published).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></time>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm prose dark:prose-invert" x-html="comment.content?.html || comment.content?.text"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="!loading && comments.length === 0">
|
||||||
|
<p class="text-sm text-surface-500">No comments yet. Be the first to share your thoughts!</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
25
_includes/components/widgets/recent-comments.njk
Normal file
25
_includes/components/widgets/recent-comments.njk
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{# Recent Comments Widget — sidebar #}
|
||||||
|
{% if recentComments and recentComments.length %}
|
||||||
|
<div class="sidebar-widget">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">Recent Comments</h3>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{% for comment in recentComments %}
|
||||||
|
<li class="text-sm">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
{% if comment.author and comment.author.photo %}
|
||||||
|
<img src="{{ comment.author.photo }}" alt="{{ comment.author.name }}"
|
||||||
|
class="w-6 h-6 rounded-full flex-shrink-0 mt-0.5" loading="lazy">
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{{ comment.author.name or "Anonymous" }}</span>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400 line-clamp-2">{{ comment.content.text | truncate(80) }}</p>
|
||||||
|
{% if comment["comment-target"] %}
|
||||||
|
<a href="{{ comment['comment-target'] }}" class="text-xs text-surface-500 hover:underline">View post</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -440,6 +440,8 @@
|
|||||||
<script type="module" src="/js/filter-container.js"></script>
|
<script type="module" src="/js/filter-container.js"></script>
|
||||||
{# Client-side webmention fetcher - supplements build-time cache with real-time data #}
|
{# Client-side webmention fetcher - supplements build-time cache with real-time data #}
|
||||||
<script src="/js/webmentions.js?v={{ '/js/webmentions.js' | hash }}" defer></script>
|
<script src="/js/webmentions.js?v={{ '/js/webmentions.js' | hash }}" defer></script>
|
||||||
|
{# Client-side comments - IndieAuth login + comment submission #}
|
||||||
|
<script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script>
|
||||||
{# Admin auth detection - shows dashboard link + FAB when logged in #}
|
{# Admin auth detection - shows dashboard link + FAB when logged in #}
|
||||||
<script src="/js/admin.js?v={{ '/js/admin.js' | hash }}" defer></script>
|
<script src="/js/admin.js?v={{ '/js/admin.js' | hash }}" defer></script>
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ withBlogSidebar: true
|
|||||||
</script>
|
</script>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{# Comments section #}
|
||||||
|
{% include "components/comments.njk" %}
|
||||||
|
|
||||||
{# Webmentions display - likes, reposts, replies #}
|
{# Webmentions display - likes, reposts, replies #}
|
||||||
{% include "components/webmentions.njk" %}
|
{% include "components/webmentions.njk" %}
|
||||||
|
|
||||||
|
|||||||
143
js/comments.js
Normal file
143
js/comments.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Client-side comments component (Alpine.js)
|
||||||
|
* Handles IndieAuth flow, comment submission, and display
|
||||||
|
*/
|
||||||
|
|
||||||
|
function commentsSection(targetUrl) {
|
||||||
|
return {
|
||||||
|
targetUrl,
|
||||||
|
user: null,
|
||||||
|
meUrl: "",
|
||||||
|
commentText: "",
|
||||||
|
comments: [],
|
||||||
|
loading: true,
|
||||||
|
authLoading: false,
|
||||||
|
submitting: false,
|
||||||
|
statusMessage: "",
|
||||||
|
statusType: "info",
|
||||||
|
maxLength: 2000,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.checkSession();
|
||||||
|
await this.loadComments();
|
||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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 || [];
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user