feat: add inbound webmentions to interactions page

- Add tabbed interface: "My Activity" (outbound) and "Received" (inbound)
- Fetch webmentions from webmention.io API client-side for real-time data
- Filter by type: likes, reposts, replies, mentions
- Show author avatar, action type badge, date, and target post
- Load more pagination for large webmention sets
- Default to inbound tab to highlight interactions from others

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-01-26 20:11:11 +01:00
parent 80ced7b148
commit ba83e3c791
+293 -4
View File
@@ -5,10 +5,35 @@ permalink: /interactions/
--- ---
<div class="page-header mb-6 sm:mb-8"> <div class="page-header mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Interactions</h1> <h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Interactions</h1>
<p class="text-surface-600 dark:text-surface-400">My engagement with content across the IndieWeb.</p> <p class="text-surface-600 dark:text-surface-400">Activity across the IndieWeb - both my engagement and responses to my content.</p>
</div> </div>
<div class="grid gap-4 sm:gap-6 md:grid-cols-2 lg:grid-cols-3"> {# Tab navigation for Outbound/Inbound #}
<div x-data="interactionsApp()" x-init="init()">
{# Tab buttons #}
<div class="flex border-b border-surface-200 dark:border-surface-700 mb-6">
<button
@click="activeTab = 'outbound'"
:class="activeTab === 'outbound' ? 'border-primary-500 text-primary-600 dark:text-primary-400' : 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors">
My Activity
<span class="ml-1 text-xs text-surface-400">(outbound)</span>
</button>
<button
@click="activeTab = 'inbound'"
:class="activeTab === 'inbound' ? 'border-primary-500 text-primary-600 dark:text-primary-400' : 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors">
Received
<span class="ml-1 text-xs text-surface-400">(inbound)</span>
<span x-show="totalInbound > 0" x-text="totalInbound" class="ml-1 px-1.5 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full"></span>
</button>
</div>
{# ===== OUTBOUND TAB - My Activity ===== #}
<div x-show="activeTab === 'outbound'" x-transition>
<p class="text-surface-600 dark:text-surface-400 text-sm mb-6">Content I've interacted with across the web.</p>
<div class="grid gap-4 sm:gap-6 md:grid-cols-2 lg:grid-cols-3">
{# Likes #} {# Likes #}
<a href="/likes/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group"> <a href="/likes/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
@@ -88,9 +113,9 @@ permalink: /interactions/
</div> </div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Photo posts and images.</p> <p class="text-surface-600 dark:text-surface-400 text-sm">Photo posts and images.</p>
</a> </a>
</div> </div>
<div class="mt-12 p-6 bg-primary-50 dark:bg-primary-900/20 rounded-lg"> <div class="mt-12 p-6 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">About IndieWeb Interactions</h2> <h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">About IndieWeb Interactions</h2>
<p class="text-surface-600 dark:text-surface-400 text-sm mb-4"> <p class="text-surface-600 dark:text-surface-400 text-sm mb-4">
These pages show different types of IndieWeb interactions I've made. Each type uses specific microformat properties These pages show different types of IndieWeb interactions I've made. Each type uses specific microformat properties
@@ -102,4 +127,268 @@ permalink: /interactions/
<li><strong>Bookmarks</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-bookmark-of</code></li> <li><strong>Bookmarks</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-bookmark-of</code></li>
<li><strong>Reposts</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-repost-of</code></li> <li><strong>Reposts</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-repost-of</code></li>
</ul> </ul>
</div>
</div>
{# ===== INBOUND TAB - Received Webmentions ===== #}
<div x-show="activeTab === 'inbound'" x-transition>
<div class="flex items-center justify-between mb-6">
<p class="text-surface-600 dark:text-surface-400 text-sm">Webmentions and interactions others have made with my content.</p>
<button
@click="fetchWebmentions()"
:disabled="loading"
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 rounded-lg transition-colors disabled:opacity-50">
<svg class="w-4 h-4" :class="loading ? 'animate-spin' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
{# Loading state #}
<div x-show="loading && !webmentions.length" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
<p class="mt-4 text-surface-500">Loading webmentions...</p>
</div>
{# Error state #}
<div x-show="error" class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 mb-6">
<p x-text="error"></p>
</div>
{# Filter by type #}
<div x-show="!loading || webmentions.length" class="flex flex-wrap gap-2 mb-6">
<button
@click="filterType = 'all'"
:class="filterType === 'all' ? 'bg-primary-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
All <span x-text="'(' + totalInbound + ')'" class="text-xs opacity-75"></span>
</button>
<button
@click="filterType = 'like-of'"
:class="filterType === 'like-of' ? 'bg-red-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
❤️ Likes <span x-text="'(' + likes.length + ')'" class="text-xs opacity-75"></span>
</button>
<button
@click="filterType = 'repost-of'"
:class="filterType === 'repost-of' ? 'bg-green-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
🔄 Reposts <span x-text="'(' + reposts.length + ')'" class="text-xs opacity-75"></span>
</button>
<button
@click="filterType = 'in-reply-to'"
:class="filterType === 'in-reply-to' ? 'bg-primary-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
💬 Replies <span x-text="'(' + replies.length + ')'" class="text-xs opacity-75"></span>
</button>
<button
@click="filterType = 'mention-of'"
:class="filterType === 'mention-of' ? 'bg-yellow-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
📣 Mentions <span x-text="'(' + mentions.length + ')'" class="text-xs opacity-75"></span>
</button>
</div>
{# Webmentions list #}
<div x-show="!loading || webmentions.length" class="space-y-4">
<template x-for="wm in filteredWebmentions" :key="wm['wm-id']">
<div class="p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<div class="flex gap-3">
{# Author avatar #}
<a :href="wm.author?.url || '#'" target="_blank" rel="noopener" class="flex-shrink-0">
<img
:src="wm.author?.photo || '/images/default-avatar.png'"
:alt="wm.author?.name || 'Anonymous'"
class="w-10 h-10 rounded-full"
loading="lazy"
onerror="this.src='/images/default-avatar.png'"
>
</a>
<div class="flex-1 min-w-0">
{# Header with author, type badge, and date #}
<div class="flex flex-wrap items-center gap-2 mb-1">
<a :href="wm.author?.url || '#'" target="_blank" rel="noopener" class="font-semibold text-surface-900 dark:text-surface-100 hover:underline" x-text="wm.author?.name || 'Anonymous'"></a>
{# Type badge #}
<span x-show="wm['wm-property'] === 'like-of'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-full">
❤️ liked
</span>
<span x-show="wm['wm-property'] === 'repost-of'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full">
🔄 reposted
</span>
<span x-show="wm['wm-property'] === 'in-reply-to'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full">
💬 replied
</span>
<span x-show="wm['wm-property'] === 'mention-of'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 rounded-full">
📣 mentioned
</span>
<span x-show="wm['wm-property'] === 'bookmark-of'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-full">
🔖 bookmarked
</span>
<a :href="wm.url || '#'" target="_blank" rel="noopener" class="text-xs text-surface-500 hover:underline">
<time :datetime="wm.published || wm['wm-received']" x-text="formatDate(wm.published || wm['wm-received'])"></time>
</a>
</div>
{# Content (for replies) #}
<div x-show="wm.content?.text" class="text-surface-700 dark:text-surface-300 text-sm mt-2" x-text="truncateText(wm.content?.text, 280)"></div>
{# Target URL - which of my posts this is about #}
<div class="mt-2 text-xs text-surface-500">
<span>on </span>
<a :href="wm['wm-target']" class="text-primary-600 dark:text-primary-400 hover:underline" x-text="formatTargetUrl(wm['wm-target'])"></a>
</div>
</div>
</div>
</div>
</template>
{# Empty state #}
<div x-show="!loading && filteredWebmentions.length === 0" class="text-center py-12 text-surface-500">
<p>No webmentions found for this filter.</p>
</div>
{# Pagination / Load more #}
<div x-show="hasMore" class="text-center pt-4">
<button
@click="loadMore()"
:disabled="loadingMore"
class="px-4 py-2 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 rounded-lg text-sm transition-colors disabled:opacity-50">
<span x-text="loadingMore ? 'Loading...' : 'Load More'"></span>
</button>
</div>
</div>
{# Info box #}
<div class="mt-12 p-6 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">About Webmentions</h2>
<p class="text-surface-600 dark:text-surface-400 text-sm mb-4">
Webmentions are a W3C standard for cross-site communication. When someone likes, reposts, or replies to my content
from their own site (or via Bluesky/Mastodon bridges), I receive a webmention notification.
</p>
<ul class="text-sm text-surface-600 dark:text-surface-400 space-y-1">
<li><strong>Likes</strong> - Someone appreciated this post</li>
<li><strong>Reposts</strong> - Someone shared this post</li>
<li><strong>Replies</strong> - Someone responded to this post</li>
<li><strong>Mentions</strong> - Someone linked to this post</li>
</ul>
</div>
</div>
</div> </div>
<script>
function interactionsApp() {
return {
activeTab: 'inbound',
loading: false,
loadingMore: false,
error: null,
webmentions: [],
filterType: 'all',
page: 0,
perPage: 50,
hasMore: true,
get likes() {
return this.webmentions.filter(wm => wm['wm-property'] === 'like-of');
},
get reposts() {
return this.webmentions.filter(wm => wm['wm-property'] === 'repost-of');
},
get replies() {
return this.webmentions.filter(wm => wm['wm-property'] === 'in-reply-to');
},
get mentions() {
return this.webmentions.filter(wm => wm['wm-property'] === 'mention-of');
},
get totalInbound() {
return this.webmentions.length;
},
get filteredWebmentions() {
if (this.filterType === 'all') return this.webmentions;
return this.webmentions.filter(wm => wm['wm-property'] === this.filterType);
},
async init() {
await this.fetchWebmentions();
},
async fetchWebmentions(reset = true) {
if (reset) {
this.page = 0;
this.webmentions = [];
this.hasMore = true;
}
this.loading = true;
this.error = null;
try {
const domain = '{{ site.webmentions.domain }}';
const url = `https://webmention.io/api/mentions.jf2?domain=${domain}&per-page=${this.perPage}&page=${this.page}&sort-by=published&sort-dir=down`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const newMentions = data.children || [];
if (reset) {
this.webmentions = newMentions;
} else {
this.webmentions = [...this.webmentions, ...newMentions];
}
this.hasMore = newMentions.length === this.perPage;
} catch (err) {
this.error = `Failed to load webmentions: ${err.message}`;
console.error('[Interactions]', err);
} finally {
this.loading = false;
}
},
async loadMore() {
this.loadingMore = true;
this.page++;
await this.fetchWebmentions(false);
this.loadingMore = false;
},
formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
},
truncateText(text, maxLen = 280) {
if (!text) return '';
if (text.length <= maxLen) return text;
return text.slice(0, maxLen).trim() + '...';
},
formatTargetUrl(url) {
if (!url) return '';
try {
const u = new URL(url);
// Return just the pathname, trimmed
let path = u.pathname;
if (path.length > 50) {
path = path.slice(0, 47) + '...';
}
return path || '/';
} catch {
return url;
}
}
};
}
</script>