mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 08:44:56 +02:00
Eliminate monotonous blue by replacing ~290 primary- references in 60 files with semantically appropriate colors: - accent (teal): links, CTAs, buttons, tabs, focus rings, spinners - purple: Funkwhale/music, photos, Mastodon/fediverse - surface (neutral): GitHub, dates/metadata, info boxes - amber: bookmarks, blogroll categories - red: likes - green: reposts - sky: replies - orange: RSS/feeds, podcasts - #0085ff: Bluesky brand - #a730b8: Mastodon brand Also updates prose link colors in tailwind.config.js, pagefind UI primary color to teal, and client-side JS color references. Confab-Link: http://localhost:8080/sessions/bd3f7012-c703-47e9-bfe2-2ad04ce1842d
534 lines
28 KiB
Plaintext
534 lines
28 KiB
Plaintext
---
|
|
layout: layouts/base.njk
|
|
title: Interactions
|
|
permalink: /interactions/
|
|
---
|
|
<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>
|
|
<p class="text-surface-600 dark:text-surface-400">Activity across the IndieWeb - both my engagement and responses to my content.</p>
|
|
</div>
|
|
|
|
<noscript>
|
|
<div class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-6">
|
|
<p class="text-amber-800 dark:text-amber-200 text-sm">The inbound webmentions tab requires JavaScript to load data from the API. Enable JavaScript for the full interactive experience. Outbound interactions are shown below.</p>
|
|
</div>
|
|
</noscript>
|
|
|
|
{# 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-accent-500 text-accent-600 dark:text-accent-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-accent-500 text-accent-600 dark:text-accent-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-accent-100 dark:bg-accent-900 text-accent-700 dark:text-accent-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 #}
|
|
<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="p-3 bg-red-100 dark:bg-red-900/30 rounded-full">
|
|
<svg class="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-red-600 dark:group-hover:text-red-400">Likes</h2>
|
|
<p class="text-sm text-surface-500">{{ collections.likes.length }} item{% if collections.likes.length != 1 %}s{% endif %}</p>
|
|
</div>
|
|
</div>
|
|
<p class="text-surface-600 dark:text-surface-400 text-sm">Content I've appreciated across the web.</p>
|
|
</a>
|
|
|
|
{# Replies #}
|
|
<a href="/replies/" 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="p-3 bg-sky-100 dark:bg-sky-900/30 rounded-full">
|
|
<svg class="w-6 h-6 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-sky-600 dark:group-hover:text-sky-400">Replies</h2>
|
|
<p class="text-sm text-surface-500">{{ collections.replies.length }} item{% if collections.replies.length != 1 %}s{% endif %}</p>
|
|
</div>
|
|
</div>
|
|
<p class="text-surface-600 dark:text-surface-400 text-sm">My responses to posts across the web.</p>
|
|
</a>
|
|
|
|
{# Bookmarks #}
|
|
<a href="/bookmarks/" 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="p-3 bg-yellow-100 dark:bg-yellow-900/30 rounded-full">
|
|
<svg class="w-6 h-6 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-amber-600 dark:group-hover:text-amber-400">Bookmarks</h2>
|
|
<p class="text-sm text-surface-500">{{ collections.bookmarks.length }} item{% if collections.bookmarks.length != 1 %}s{% endif %}</p>
|
|
</div>
|
|
</div>
|
|
<p class="text-surface-600 dark:text-surface-400 text-sm">Links I've saved for later.</p>
|
|
</a>
|
|
|
|
{# Reposts #}
|
|
<a href="/reposts/" 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="p-3 bg-green-100 dark:bg-green-900/30 rounded-full">
|
|
<svg class="w-6 h-6 text-green-500" 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>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-green-600 dark:group-hover:text-green-400">Reposts</h2>
|
|
<p class="text-sm text-surface-500">{{ collections.reposts.length }} item{% if collections.reposts.length != 1 %}s{% endif %}</p>
|
|
</div>
|
|
</div>
|
|
<p class="text-surface-600 dark:text-surface-400 text-sm">Content I've shared from others.</p>
|
|
</a>
|
|
|
|
{# Photos #}
|
|
<a href="/photos/" 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="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
|
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-purple-600 dark:group-hover:text-purple-400">Photos</h2>
|
|
<p class="text-sm text-surface-500">{{ collections.photos.length }} item{% if collections.photos.length != 1 %}s{% endif %}</p>
|
|
</div>
|
|
</div>
|
|
<p class="text-surface-600 dark:text-surface-400 text-sm">Photo posts and images.</p>
|
|
</a>
|
|
</div>
|
|
|
|
<div class="mt-12 p-6 bg-surface-100 dark:bg-surface-800/50 rounded-lg">
|
|
<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">
|
|
These pages show different types of IndieWeb interactions I've made. Each type uses specific microformat properties
|
|
to indicate the relationship to the original content.
|
|
</p>
|
|
<ul class="text-sm text-surface-600 dark:text-surface-400 space-y-1">
|
|
<li><strong>Likes</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-like-of</code></li>
|
|
<li><strong>Replies</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-in-reply-to</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>
|
|
</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-accent-500"></div>
|
|
<p class="mt-4 text-surface-500">Loading webmentions...</p>
|
|
</div>
|
|
|
|
{# Setup required state — shown when webmentions proxy is not configured #}
|
|
<div x-show="notConfigured" class="p-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-6">
|
|
<h3 class="text-lg font-semibold text-amber-800 dark:text-amber-200 mb-3">Webmentions Not Configured</h3>
|
|
<p class="text-amber-700 dark:text-amber-300 text-sm mb-4">
|
|
To receive inbound webmentions, you need to set up the webmentions proxy plugin
|
|
(<code class="bg-amber-100 dark:bg-amber-900/40 px-1 rounded">@rmdes/indiekit-endpoint-webmentions-proxy</code>).
|
|
</p>
|
|
<ol class="text-sm text-amber-700 dark:text-amber-300 space-y-2 list-decimal list-inside">
|
|
<li>Register your domain at <a href="https://webmention.io" target="_blank" rel="noopener" class="underline font-medium">webmention.io</a> and get your API token</li>
|
|
<li>Set the <code class="bg-amber-100 dark:bg-amber-900/40 px-1 rounded">WEBMENTION_IO_TOKEN</code> environment variable</li>
|
|
<li>Ensure the webmentions proxy plugin is installed and configured in your Indiekit config</li>
|
|
<li>Restart Indiekit to apply the changes</li>
|
|
</ol>
|
|
</div>
|
|
|
|
{# Error state — only shown for real errors, not missing config #}
|
|
<div x-show="error && !notConfigured" 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="!notConfigured && (!loading || webmentions.length)" class="flex flex-wrap gap-2 mb-6">
|
|
<button
|
|
@click="filterType = 'all'"
|
|
:class="filterType === 'all' ? 'bg-accent-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-sky-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="!notConfigured && (!loading || webmentions.length)" class="space-y-4">
|
|
<template x-for="wm in paginatedWebmentions" :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.svg'"
|
|
:alt="wm.author?.name || 'Anonymous'"
|
|
class="w-10 h-10 rounded-full"
|
|
loading="lazy"
|
|
onerror="this.src='/images/default-avatar.svg'"
|
|
>
|
|
</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-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-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>
|
|
|
|
{# Platform badge (from conversations API) #}
|
|
<span x-show="wm.platform === 'mastodon'" class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-full" title="Mastodon">
|
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
|
</span>
|
|
<span x-show="wm.platform === 'bluesky'" class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400 rounded-full" title="Bluesky">
|
|
<svg class="w-3 h-3" viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg>
|
|
</span>
|
|
<span x-show="wm.platform === 'activitypub'" class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full" title="Fediverse (ActivityPub)">
|
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
|
</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-accent-600 dark:text-accent-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-surface-100 dark:bg-surface-800/50 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>
|
|
|
|
<script>
|
|
function interactionsApp() {
|
|
return {
|
|
activeTab: 'inbound',
|
|
loading: false,
|
|
loadingMore: false,
|
|
error: null,
|
|
notConfigured: false,
|
|
webmentions: [],
|
|
filterType: 'all',
|
|
page: 0,
|
|
perPage: 50,
|
|
hasMore: true,
|
|
refreshInterval: null,
|
|
|
|
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);
|
|
},
|
|
|
|
get paginatedWebmentions() {
|
|
// Client-side pagination of filtered results
|
|
return this.filteredWebmentions;
|
|
},
|
|
|
|
async init() {
|
|
await this.fetchWebmentions();
|
|
// Auto-refresh every 5 minutes (skip if not configured)
|
|
if (!this.notConfigured) {
|
|
this.refreshInterval = setInterval(() => this.fetchWebmentions(true), 5 * 60 * 1000);
|
|
}
|
|
},
|
|
|
|
async fetchWebmentions(silent = false) {
|
|
if (!silent) {
|
|
this.loading = true;
|
|
this.page = 0;
|
|
this.webmentions = [];
|
|
this.hasMore = true;
|
|
}
|
|
this.error = null;
|
|
|
|
try {
|
|
// Fetch from both webmention-io and conversations APIs in parallel
|
|
const wmUrl = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`;
|
|
const convUrl = `/conversations/api/mentions?per-page=${this.perPage}&page=${this.page}`;
|
|
|
|
const [wmResult, convResult] = await Promise.allSettled([
|
|
fetch(wmUrl).then(r => {
|
|
if (r.status === 404) return { children: [], notConfigured: true };
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
return r.json();
|
|
}),
|
|
fetch(convUrl).then(r => {
|
|
if (!r.ok) return { children: [] };
|
|
return r.json();
|
|
}).catch(() => ({ children: [] })),
|
|
]);
|
|
|
|
const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] };
|
|
const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] };
|
|
|
|
// Check if webmention-io is configured
|
|
if (wmData.notConfigured && (!convData.children || !convData.children.length)) {
|
|
this.notConfigured = true;
|
|
return;
|
|
}
|
|
this.notConfigured = false;
|
|
|
|
// Merge and deduplicate - conversations items (with platform field) take priority
|
|
const merged = this.mergeAndDeduplicate(
|
|
wmData.children || [],
|
|
convData.children || []
|
|
);
|
|
|
|
// Sort by date, newest first
|
|
merged.sort((a, b) => {
|
|
const dateA = new Date(a.published || a['wm-received'] || 0);
|
|
const dateB = new Date(b.published || b['wm-received'] || 0);
|
|
return dateB - dateA;
|
|
});
|
|
|
|
this.webmentions = merged;
|
|
this.hasMore = (wmData.children || []).length === this.perPage;
|
|
} catch (err) {
|
|
this.error = `Failed to load webmentions: ${err.message}`;
|
|
console.error('[Interactions]', err);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
detectPlatform(item) {
|
|
const source = item['wm-source'] || '';
|
|
const authorUrl = item.author?.url || '';
|
|
// Bridgy source URLs: brid.gy/{action}/{platform}/...
|
|
if (source.includes('brid.gy/') && source.includes('/mastodon/')) return 'mastodon';
|
|
if (source.includes('brid.gy/') && source.includes('/bluesky/')) return 'bluesky';
|
|
// Author URL heuristics
|
|
if (authorUrl.includes('bsky.app')) return 'bluesky';
|
|
if (authorUrl.includes('mstdn') || authorUrl.includes('mastodon') || authorUrl.includes('social.')) return 'mastodon';
|
|
return null;
|
|
},
|
|
|
|
mergeAndDeduplicate(wmItems, convItems) {
|
|
// Build a Set of source URLs from conversations for dedup
|
|
const convUrls = new Set(convItems.map(c => c.url).filter(Boolean));
|
|
const seen = new Set();
|
|
const result = [];
|
|
|
|
// Add all conversations items first (they have richer metadata)
|
|
for (const item of convItems) {
|
|
const key = item['wm-id'] || item.url;
|
|
if (key && !seen.has(key)) {
|
|
seen.add(key);
|
|
result.push(item);
|
|
}
|
|
}
|
|
|
|
// Add webmention-io items that aren't duplicated by conversations
|
|
for (const item of wmItems) {
|
|
const wmKey = item['wm-id'];
|
|
if (seen.has(wmKey)) continue;
|
|
|
|
// Also check if this webmention's source URL matches a conversations item
|
|
// (same interaction from Bridgy webmention AND direct poll)
|
|
if (item.url && convUrls.has(item.url)) continue;
|
|
|
|
// Infer platform from Bridgy source URL or author URL
|
|
if (!item.platform) {
|
|
const detected = this.detectPlatform(item);
|
|
if (detected) item.platform = detected;
|
|
}
|
|
|
|
seen.add(wmKey);
|
|
result.push(item);
|
|
}
|
|
|
|
return result;
|
|
},
|
|
|
|
async loadMore() {
|
|
this.loadingMore = true;
|
|
this.page++;
|
|
|
|
try {
|
|
const wmUrl = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`;
|
|
const convUrl = `/conversations/api/mentions?per-page=${this.perPage}&page=${this.page}`;
|
|
|
|
const [wmResult, convResult] = await Promise.allSettled([
|
|
fetch(wmUrl).then(r => r.ok ? r.json() : { children: [] }),
|
|
fetch(convUrl).then(r => r.ok ? r.json() : { children: [] }).catch(() => ({ children: [] })),
|
|
]);
|
|
|
|
const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] };
|
|
const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] };
|
|
|
|
const merged = this.mergeAndDeduplicate(
|
|
wmData.children || [],
|
|
convData.children || []
|
|
);
|
|
|
|
this.webmentions = [...this.webmentions, ...merged];
|
|
this.hasMore = (wmData.children || []).length === this.perPage;
|
|
} catch (err) {
|
|
this.error = `Failed to load more: ${err.message}`;
|
|
} finally {
|
|
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>
|