adrian-altner.de/src/layouts/Post.astro
Adrian Altner 4bf4eb03b1 Add new posts for Image Voice Memos, Initial VPS Setup on Debian, Local Webmention Avatars, Security Headers for Astro with Caddy, and Setting up Forgejo Actions Runner
- Created a new post on Image Voice Memos detailing a macOS app for browsing photos and recording voice memos with automatic transcription.
- Added a guide for Initial VPS Setup on Debian covering system updates, user creation, and SSH hardening.
- Introduced a post on caching webmention avatars locally at build time to enhance privacy and comply with CSP.
- Documented the implementation of security headers for an Astro site behind Caddy, focusing on GDPR compliance and CSP.
- Set up a Forgejo Actions runner for self-hosted CI/CD, detailing the installation and configuration process for automated deployments.
2026-04-22 23:00:10 +02:00

247 lines
5.9 KiB
Text

---
import { Image } from 'astro:assets';
import { getEntry } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import FormattedDate from '~/components/FormattedDate.astro';
import Webmentions from '~/components/Webmentions.astro';
import BaseLayout from '~/layouts/BaseLayout.astro';
import { DEFAULT_LOCALE, type Locale } from '~/consts';
import { categoryHref, entryHref, entrySlug, findTranslation, getSeries, postHref, tagHref } from '~/i18n/posts';
import { getLocaleFromUrl, t } from '~/i18n/ui';
type Props = CollectionEntry<'posts'>['data'] & {
locale?: Locale;
entry?: CollectionEntry<'posts'>;
};
const {
title,
description,
pubDate,
updatedDate,
heroImage,
hideHero,
category,
tags,
url,
repo,
entry,
locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE,
} = Astro.props;
const categoryEntry = category ? await getEntry(category) : undefined;
const otherLocale: Locale = locale === 'de' ? 'en' : 'de';
const translation = entry ? await findTranslation(entry, otherLocale) : undefined;
const series = entry ? await getSeries(entry) : undefined;
const currentSlug = entry ? entrySlug(entry) : undefined;
const seriesPosition = series && currentSlug ? series.entries.findIndex((e) => entrySlug(e) === currentSlug) : -1;
---
<BaseLayout title={title} description={description} image={heroImage} locale={locale} entry={entry}>
<article class="h-entry">
<a href={Astro.url.pathname} class="u-url" hidden></a>
<div class="hero-image">
{
heroImage && !hideHero && (
<Image
class="u-photo"
width={1020}
height={510}
src={heroImage}
alt=""
/>
)
}
</div>
<div class="prose">
<div class="title">
<div class="date">
<FormattedDate date={pubDate} locale={locale} class="dt-published" />
{
updatedDate && (
<div class="last-updated-on">
{t(locale, 'post.lastUpdated')}{' '}
<FormattedDate date={updatedDate} locale={locale} class="dt-updated" />
</div>
)
}
</div>
<h1 class="p-name">{title}</h1>
<p class="p-summary" hidden>{description}</p>
<p class="p-author h-card" hidden>
<a class="u-url p-name" href={new URL('/', Astro.site)}>Adrian Altner</a>
</p>
{
categoryEntry && (
<p class="category">
{t(locale, 'post.category')}:{' '}
<a href={categoryHref(categoryEntry)} class="p-category">
{categoryEntry.data.name}
</a>
</p>
)
}
{
tags && tags.length > 0 && (
<p class="tags">
{t(locale, 'post.tags')}:{' '}
{tags.map((name, i) => (<>{i > 0 && ', '}<a href={tagHref(locale, name)} class="p-category">{name}</a></>))}
</p>
)
}
{
(url || repo) && (
<p class="project-links">
{url && (
<a href={url} rel="noopener" class="u-url">
{t(locale, 'post.projectDemo')}
</a>
)}
{url && repo && ' · '}
{repo && (
<a href={repo} rel="noopener">
{t(locale, 'post.projectRepo')}
</a>
)}
</p>
)
}
<hr />
</div>
{
series && seriesPosition >= 0 && (
<aside class="series-box">
<p class="series-heading">
{t(locale, 'post.series')}{' '}
<a href={postHref(series.parent)}>{series.parent.data.title}</a>
{' · '}
<span class="series-position">
{t(locale, 'post.seriesPart')
.replace('{n}', String(seriesPosition + 1))
.replace('{total}', String(series.entries.length))}
</span>
</p>
<ol class="series-list">
{series.entries.map((e) => {
const isCurrent = entrySlug(e) === currentSlug;
return (
<li class={isCurrent ? 'is-current' : ''}>
{isCurrent ? (
<span>{e.data.title}</span>
) : (
<a href={postHref(e)}>{e.data.title}</a>
)}
</li>
);
})}
</ol>
</aside>
)
}
{
translation && (
<aside class="translation-notice" lang={otherLocale}>
<span>{t(locale, 'post.translationAvailable')}</span>{' '}
<a href={entryHref(translation)} hreflang={otherLocale}>
{t(locale, 'post.translationLink')}
</a>
</aside>
)
}
<div class="e-content">
<slot />
</div>
<Webmentions target={new URL(Astro.url.pathname, Astro.site)} locale={locale} />
</div>
</article>
</BaseLayout>
<style>
main {
width: calc(100% - 2em);
max-width: 100%;
margin: 0;
}
.hero-image {
width: 100%;
}
.hero-image img {
display: block;
margin: 0 auto;
border-radius: 12px;
box-shadow: var(--box-shadow);
}
.prose {
width: 720px;
max-width: calc(100% - 2em);
margin: auto;
padding: 1em;
color: rgb(var(--gray-dark));
}
.title {
margin-bottom: 1em;
padding: 1em 0;
text-align: center;
line-height: 1;
}
.title h1 {
margin: 0 0 0.5em 0;
}
.date {
margin-bottom: 0.5em;
color: rgb(var(--gray));
}
.last-updated-on {
font-style: italic;
}
.translation-notice {
margin: 0 0 2em 0;
padding: 0.75em 1em;
background: rgba(var(--gray-light), 0.6);
border-left: 3px solid var(--accent);
border-radius: 4px;
font-size: 0.95em;
}
.translation-notice a {
color: var(--accent);
font-weight: 600;
}
.series-box {
margin: 0 0 2em 0;
padding: 1em 1.25em;
background: rgba(var(--gray-light), 0.5);
border-left: 3px solid var(--accent);
border-radius: 4px;
font-size: 0.95em;
}
.series-heading {
margin: 0 0 0.5em 0;
font-weight: 600;
}
.series-heading a {
color: var(--accent);
}
.series-position {
font-weight: 400;
color: rgb(var(--gray));
}
.series-list {
margin: 0;
padding-left: 1.5em;
}
.series-list li {
margin: 0.2em 0;
}
.series-list li.is-current {
font-weight: 600;
color: rgb(var(--gray-dark));
}
.project-links {
margin: 0.5em 0 0 0;
font-size: 0.95em;
}
.project-links a {
color: var(--accent);
font-weight: 600;
}
</style>