Implement Webmention functionality: add Webmentions component, update deploy script, and enhance UI with social links and localization

This commit is contained in:
Adrian Altner 2026-04-21 23:46:18 +02:00
parent abbf2d9a0b
commit 934a9f2338
8 changed files with 460 additions and 10 deletions

View file

@ -45,6 +45,10 @@ const rssHref = new URL(locale === 'de' ? 'rss.xml' : 'en/rss.xml', Astro.site);
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
<!-- Webmention endpoints (webmention.io) -->
<link rel="webmention" href="https://webmention.io/adrian-altner.de/webmention" />
<link rel="pingback" href="https://webmention.io/adrian-altner.de/xmlrpc" />
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />

View file

@ -17,6 +17,31 @@ const today = new Date();
<footer>
<div class="footer__inner">
<span>&copy; {today.getFullYear()} Adrian Altner</span>
<nav class="footer__social" aria-label="Social">
<a
href="https://mastodon.social/@altner"
rel="me noopener"
target="_blank"
aria-label="Mastodon"
>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor">
<path
d="M21.58 13.91c-.29 1.48-2.58 3.1-5.21 3.41-1.37.17-2.72.32-4.16.25-2.35-.11-4.2-.56-4.2-.56 0 .23.01.45.04.65.3 2.28 2.26 2.42 4.11 2.48 1.87.07 3.54-.46 3.54-.46l.08 1.69s-1.31.7-3.64.83c-1.28.07-2.88-.03-4.74-.52-4.04-1.07-4.73-5.38-4.84-9.74-.03-1.3-.01-2.52-.01-3.54 0-4.46 2.92-5.77 2.92-5.77C6.95.89 9.48.72 12.11.7h.06c2.63.02 5.17.19 6.64.87 0 0 2.92 1.31 2.92 5.77 0 0 .04 3.29-.41 5.58"
/>
<path
d="M18.66 7.63v5.45h-2.16V7.79c0-1.09-.46-1.64-1.38-1.64-1.01 0-1.52.65-1.52 1.95v2.82h-2.14V8.1c0-1.3-.51-1.95-1.52-1.95-.92 0-1.38.55-1.38 1.64v5.29H6.4V7.63c0-1.09.28-1.95.83-2.59.57-.64 1.32-.97 2.25-.97 1.08 0 1.9.41 2.43 1.24L12 6.08l.54-.77c.53-.83 1.35-1.24 2.43-1.24.93 0 1.68.33 2.25.97.55.64.83 1.5.83 2.59"
fill="var(--bg, #fff)"
/>
</svg>
</a>
<a href="https://github.com/altner" rel="me noopener" target="_blank" aria-label="GitHub">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor">
<path
d="M12 .5a12 12 0 0 0-3.79 23.4c.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.21.08 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.61-2.67-.31-5.47-1.34-5.47-5.95 0-1.31.47-2.39 1.24-3.23-.12-.31-.54-1.53.12-3.19 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.19.77.84 1.24 1.92 1.24 3.23 0 4.62-2.81 5.63-5.49 5.94.43.37.82 1.1.82 2.22v3.29c0 .32.22.7.83.58A12 12 0 0 0 12 .5Z"
/>
</svg>
</a>
</nav>
<nav class="footer__links" aria-label="Legal">
<a href={localizePath(`/${contactSegment}`, locale)}>{t(locale, 'footer.contact')}</a>
<a href={localizePath(`/${imprintSegment}`, locale)}>{t(locale, 'footer.imprint')}</a>
@ -49,4 +74,18 @@ const today = new Date();
.footer__links a:hover {
color: var(--accent);
}
.footer__social {
display: flex;
align-items: center;
gap: 0.75em;
line-height: 1;
}
.footer__social a {
display: inline-flex;
align-items: center;
color: rgb(var(--gray));
}
.footer__social a:hover {
color: var(--accent);
}
</style>

View file

@ -5,13 +5,18 @@ import { getLocaleFromUrl } from '~/i18n/ui';
interface Props {
date: Date;
locale?: Locale;
class?: string;
}
const { date, locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE } = Astro.props;
const {
date,
locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE,
class: className,
} = Astro.props;
const tag = locale === 'de' ? 'de-DE' : 'en-US';
---
<time datetime={date.toISOString()}>
<time datetime={date.toISOString()} class={className}>
{
date.toLocaleDateString(tag, {
year: 'numeric',

View file

@ -0,0 +1,247 @@
---
import { DEFAULT_LOCALE, type Locale } from '~/consts';
import { getLocaleFromUrl, t } from '~/i18n/ui';
import { getMentionsFor, groupMentions, type WMEntry } from '~/lib/webmentions';
interface Props {
target: string | URL;
locale?: Locale;
}
const { target, locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE } = Astro.props;
const all = await getMentionsFor(target);
const { likes, reposts, replies, mentions } = groupMentions(all);
const facepile = [...likes, ...reposts];
function authorInitial(m: WMEntry) {
return m.author?.name?.trim()?.[0]?.toUpperCase() ?? '?';
}
function formatDate(iso?: string) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
const hasAny = facepile.length > 0 || replies.length > 0 || mentions.length > 0;
---
{
hasAny && (
<section class="webmentions" aria-labelledby="webmentions-heading">
<h2 id="webmentions-heading">{t(locale, 'webmentions.title')}</h2>
{facepile.length > 0 && (
<div class="facepile-group">
{likes.length > 0 && (
<div class="facepile">
<h3>
{likes.length} {t(locale, likes.length === 1 ? 'webmentions.like' : 'webmentions.likes')}
</h3>
<ul>
{likes.map((m) => (
<li>
<a
href={m.url}
title={m.author?.name ?? m.url}
rel="noopener nofollow external"
>
{m.author?.photo ? (
<img src={m.author.photo} alt="" loading="lazy" />
) : (
<span class="avatar-fallback">{authorInitial(m)}</span>
)}
</a>
</li>
))}
</ul>
</div>
)}
{reposts.length > 0 && (
<div class="facepile">
<h3>
{reposts.length}{' '}
{t(locale, reposts.length === 1 ? 'webmentions.repost' : 'webmentions.reposts')}
</h3>
<ul>
{reposts.map((m) => (
<li>
<a
href={m.url}
title={m.author?.name ?? m.url}
rel="noopener nofollow external"
>
{m.author?.photo ? (
<img src={m.author.photo} alt="" loading="lazy" />
) : (
<span class="avatar-fallback">{authorInitial(m)}</span>
)}
</a>
</li>
))}
</ul>
</div>
)}
</div>
)}
{replies.length > 0 && (
<div class="replies">
<h3>{t(locale, 'webmentions.replies')}</h3>
<ol>
{replies.map((m) => (
<li>
<div class="meta">
{m.author?.photo && (
<img src={m.author.photo} alt="" class="avatar" loading="lazy" />
)}
<a
href={m.author?.url ?? m.url}
rel="noopener nofollow external"
class="author"
>
{m.author?.name ?? m.url}
</a>
<a href={m.url} rel="noopener nofollow external" class="permalink">
<time datetime={m['wm-received'] ?? m.published}>
{formatDate(m['wm-received'] ?? m.published)}
</time>
</a>
</div>
{m.content?.text && <p>{m.content.text}</p>}
</li>
))}
</ol>
</div>
)}
{mentions.length > 0 && (
<div class="mentions">
<h3>{t(locale, 'webmentions.mentions')}</h3>
<ul>
{mentions.map((m) => (
<li>
<a href={m.url} rel="noopener nofollow external">
{m.author?.name ?? m.url}
</a>
{m['wm-received'] && (
<>
{' · '}
<time datetime={m['wm-received']}>{formatDate(m['wm-received'])}</time>
</>
)}
</li>
))}
</ul>
</div>
)}
</section>
)
}
<style>
.webmentions {
margin-top: 3em;
padding-top: 2em;
border-top: 1px solid rgba(var(--gray-light), 1);
}
.webmentions h2 {
margin: 0 0 1em;
font-size: 1.25em;
}
.webmentions h3 {
margin: 0 0 0.5em;
font-size: 1em;
color: rgb(var(--gray));
font-weight: 600;
}
.facepile-group {
display: flex;
flex-wrap: wrap;
gap: 2em;
margin-bottom: 2em;
}
.facepile ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.facepile a {
display: inline-block;
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
background: rgba(var(--gray-light), 1);
}
.facepile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-weight: 600;
color: rgb(var(--gray-dark));
}
.replies ol {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.25em;
}
.replies li {
padding: 0.75em 1em;
background: rgba(var(--gray-light), 0.4);
border-radius: 8px;
}
.replies .meta {
display: flex;
align-items: center;
gap: 0.6em;
font-size: 0.9em;
margin-bottom: 0.25em;
}
.replies .avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.replies .author {
font-weight: 600;
}
.replies .permalink {
margin-left: auto;
color: rgb(var(--gray));
font-size: 0.85em;
}
.replies p {
margin: 0;
}
.mentions ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3em;
font-size: 0.9em;
}
</style>