feat: add AboutPage component with personal information and JSON-LD schema support
All checks were successful
Deploy / deploy (push) Successful in 47s

- Created AboutPage.astro to showcase personal details, including bio, contact information, and social media links.
- Implemented localization support for German and English languages.
- Added JsonLd.astro component for structured data representation using JSON-LD.
This commit is contained in:
Adrian Altner 2026-04-23 00:54:16 +02:00
parent ae5c2564d5
commit 200d0b7181
9 changed files with 577 additions and 22 deletions

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

BIN
src/assets/me-bangkok.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
src/assets/me.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

@ -0,0 +1,558 @@
---
import { Image } from 'astro:assets';
import meBangkok from '~/assets/me-bangkok.jpg';
import JsonLd from '~/components/JsonLd.astro';
import BaseLayout from '~/layouts/BaseLayout.astro';
import type { Locale } from '~/consts';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const identityLinks = [
{
platform: 'Mastodon',
handle: '@altner@mastodon.social',
href: 'https://mastodon.social/@altner',
rel: 'me noopener noreferrer',
},
{
platform: 'Instagram',
handle: '@adrian.altner',
href: 'https://www.instagram.com/adrian.altner/',
rel: 'me noopener noreferrer',
},
];
const copy = {
de: {
title: 'Über mich',
description: 'Über Adrian Altner Leidenschaft für das Web, Fotografie und Reisen.',
url: 'https://adrian-altner.de/ueber-mich',
country: 'Deutschland',
bio: [
'Begeistert vom Web seit 1997. Autodidakt aus Überzeugung ich baue Dinge zum Vergnügen und bringe mir selbst bei, was mich gerade interessiert.',
'Fotografie ist eines meiner Hauptthemen. Meistens auf Reisen oder bei Photowalks Straßenszenen, Architektur und das Licht, das nie wartet.',
],
contactHref: '/kontakt',
contactLabel: 'Kontakt →',
headings: { web: 'Im Netz', now: 'Gerade', site: 'Diese Seite' },
webNote: {
before: 'Diese Links tragen ',
code: 'rel="me"',
between: ' zur ',
linkLabel: 'IndieWeb-Identitätsverifikation',
after: '. Meine kanonische Identität ist ',
canonical: 'adrian-altner.de',
end: '.',
},
now: [
'Ich arbeite einen Stapel Fotos aus meiner letzten Reise nach Südostasien auf. In der Freizeit lerne ich mehr über Container-Orchestrierung und Selfhosting. Ich lese gerade zum zweiten Mal ',
'The Pragmatic Programmer',
'.',
],
nowNote: {
before: 'Inspiriert von ',
link: 'nownownow.com',
after: '. Zuletzt aktualisiert April 2026.',
},
site: [
{
before: 'Gebaut mit ',
link: { href: 'https://astro.build', label: 'Astro' },
after:
', betrieben auf einem selbst gehosteten VPS in Deutschland. Inhalte liegen als Markdown-Dateien vor, versioniert in Git. Kein Tracking, keine Cookies, keine JavaScript-Frameworks.',
},
{
before: 'Diese Seite unterstützt ',
link: { href: 'https://indieweb.org/Webmention', label: 'Webmentions' },
middle: ' wer von der eigenen Website hierüber schreibt, bekomme ich möglicherweise mit. Neue Beiträge werden via ',
link2: { href: 'https://indieweb.org/POSSE', label: 'POSSE' },
after: ' auf Mastodon syndiziert.',
},
],
photoAlt: 'Adrian Altner in Bangkok',
},
en: {
title: 'About',
description: 'About Adrian Altner passionate about the web, photography, and travel.',
url: 'https://adrian-altner.de/en/about',
country: 'Germany',
bio: [
'Fascinated by the web since 1997. Self-taught by conviction — I build things for fun and teach myself whatever happens to catch my interest.',
'Photography is one of my main pursuits. Usually while travelling or on photowalks — street scenes, architecture, and the light that never waits.',
],
contactHref: '/en/contact',
contactLabel: 'Contact →',
headings: { web: 'Elsewhere', now: 'Now', site: 'This site' },
webNote: {
before: 'These links carry ',
code: 'rel="me"',
between: ' for ',
linkLabel: 'IndieWeb identity verification',
after: '. My canonical identity is ',
canonical: 'adrian-altner.de',
end: '.',
},
now: [
"Working through a stack of photos from my latest trip to Southeast Asia. In my spare time I'm learning more about container orchestration and self-hosting. Currently reading ",
'The Pragmatic Programmer',
' for the second time.',
],
nowNote: {
before: 'Inspired by ',
link: 'nownownow.com',
after: '. Last updated April 2026.',
},
site: [
{
before: 'Built with ',
link: { href: 'https://astro.build', label: 'Astro' },
after:
', running on a self-hosted VPS in Germany. Content lives as Markdown files, versioned in Git. No tracking, no cookies, no JavaScript frameworks.',
},
{
before: 'This site supports ',
link: { href: 'https://indieweb.org/Webmention', label: 'Webmentions' },
middle: " if you write about something here from your own site, there's a good chance I'll see it. New posts are syndicated to Mastodon via ",
link2: { href: 'https://indieweb.org/POSSE', label: 'POSSE' },
after: '.',
},
],
photoAlt: 'Adrian Altner in Bangkok',
},
} as const;
const t = copy[locale];
const profilePageSchema = {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
url: t.url,
...(locale === 'en' ? { inLanguage: 'en' } : {}),
mainEntity: {
'@type': 'Person',
name: 'Adrian Altner',
url: 'https://adrian-altner.de',
sameAs: [
'https://mastodon.social/@altner',
'https://www.instagram.com/adrian.altner/',
],
},
};
const techList = [
'Astro 6 · SSR · Node.js',
'Microformats2 · Webmentions · POSSE',
'Self-hosted · Germany · No CDN',
'RSS feeds for all content types',
];
---
<BaseLayout
title={t.title}
description={t.description}
locale={locale}
bodyClass="about-page"
>
<JsonLd schema={profilePageSchema} />
<div class="hero h-card">
<div class="hero__grain" aria-hidden="true"></div>
<div class="hero__inner">
<a href="https://adrian-altner.de" class="u-url hidden-url" rel="me" aria-hidden="true">adrian-altner.de</a>
<div class="hero__row">
<div class="hero__photo-wrap">
<Image
src={meBangkok}
alt={t.photoAlt}
class="hero__photo u-photo"
width={480}
height={640}
/>
</div>
<div class="hero__bio">
<h1 class="hero__name p-name">Adrian Altner</h1>
<p class="hero__location">
<span class="p-locality">Dresden</span>,
<span class="p-region">SN</span>,
<span class="p-country-name">{t.country}</span>
</p>
<div class="hero__bio-text p-note">
{t.bio.map((paragraph) => <p>{paragraph}</p>)}
</div>
<div class="hero__contact">
<a class="hero__contact-link" href={t.contactHref}>{t.contactLabel}</a>
</div>
</div>
</div>
</div>
</div>
<div class="content">
<div class="content__inner">
<section class="section" aria-labelledby="web-heading">
<h2 class="section__heading" id="web-heading">{t.headings.web}</h2>
<ul class="identity-list">
{identityLinks.map((link) => (
<li class="identity-item">
<a
href={link.href}
class="identity-link"
rel={link.rel}
target="_blank"
>
<span class="identity-platform">{link.platform}</span>
<span class="identity-handle">{link.handle}</span>
<svg class="identity-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="7" y1="17" x2="17" y2="7"></line>
<polyline points="7 7 17 7 17 17"></polyline>
</svg>
</a>
</li>
))}
</ul>
<p class="section__note">
{t.webNote.before}<code>{t.webNote.code}</code>{t.webNote.between}
<a href="https://indieweb.org/rel-me" target="_blank" rel="noopener noreferrer">{t.webNote.linkLabel}</a>{t.webNote.after}
<a href="https://adrian-altner.de" rel="me">{t.webNote.canonical}</a>{t.webNote.end}
</p>
</section>
<section class="section" aria-labelledby="now-heading">
<h2 class="section__heading" id="now-heading">{t.headings.now}</h2>
<div class="prose">
<p>{t.now[0]}<em>{t.now[1]}</em>{t.now[2]}</p>
<p class="section__note">
{t.nowNote.before}<a href="https://nownownow.com" target="_blank" rel="noopener noreferrer">{t.nowNote.link}</a>{t.nowNote.after}
</p>
</div>
</section>
<section class="section" aria-labelledby="site-heading">
<h2 class="section__heading" id="site-heading">{t.headings.site}</h2>
<div class="prose">
<p>
{t.site[0].before}
<a href={t.site[0].link.href} target="_blank" rel="noopener noreferrer">{t.site[0].link.label}</a>
{t.site[0].after}
</p>
<p>
{t.site[1].before}
<a href={t.site[1].link.href} target="_blank" rel="noopener noreferrer">{t.site[1].link.label}</a>
{t.site[1].middle}
<a href={t.site[1].link2.href} target="_blank" rel="noopener noreferrer">{t.site[1].link2.label}</a>
{t.site[1].after}
</p>
</div>
<ul class="tech-list">
{techList.map((item) => <li>{item}</li>)}
</ul>
</section>
</div>
</div>
</BaseLayout>
<style>
.hero {
--content-wide: 1440px;
position: relative;
background: rgb(var(--black));
overflow: hidden;
}
.hidden-url {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.hero__grain {
position: absolute;
inset: 0;
opacity: 0.035;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 180px;
}
.hero__inner {
position: relative;
max-width: var(--content-wide);
margin: 0 auto;
display: flex;
align-items: flex-start;
}
.hero__row {
display: contents;
}
.hero__photo-wrap {
width: 440px;
flex-shrink: 0;
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.4);
}
.hero__photo {
width: 440px;
height: auto;
display: block;
border-radius: 0;
}
.hero__bio {
flex: 0 1 auto;
min-width: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 4rem 2.5rem 3.5rem;
color: rgba(255, 255, 255, 0.78);
}
.hero__name {
font-size: clamp(2.5rem, 5vw, 3.5rem);
font-weight: 200;
letter-spacing: -0.03em;
line-height: 1.1;
color: #fff;
margin-bottom: 0.1rem;
}
.hero__location {
font-size: 0.8rem;
font-family: var(--font-maple-mono);
color: rgba(255, 255, 255, 0.55);
letter-spacing: 0.03em;
margin-bottom: 0.75rem;
}
.hero__bio-text p {
font-size: 1rem;
line-height: 1.75;
color: rgba(255, 255, 255, 0.78);
margin-bottom: 0.85rem;
max-width: 48ch;
text-wrap: balance;
}
.hero__bio-text p:last-child {
margin-bottom: 0;
}
.hero__contact {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.hero__contact-link {
font-size: 0.88rem;
font-family: var(--font-maple-mono);
color: rgba(255, 255, 255, 0.78);
text-decoration: none;
letter-spacing: 0.02em;
transition: color 0.2s;
}
.hero__contact-link:hover {
color: var(--accent);
}
.content {
--content-wide: 1200px;
background: #fff;
padding: 3.5rem 0 5rem;
}
.content__inner {
max-width: var(--content-wide);
margin: 0 auto;
padding: 0 2.5rem;
display: flex;
flex-direction: column;
gap: 0;
}
.section {
padding: 2.5rem 0;
border-top: 1px solid rgb(var(--gray-light));
}
.section:first-child {
border-top: none;
padding-top: 0;
}
.section__heading {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 1.5rem;
}
.section__heading a {
color: inherit;
text-decoration: none;
}
.section__heading a:hover {
color: rgb(var(--gray-dark));
}
.section__note {
font-size: 0.82rem;
color: rgb(var(--gray));
line-height: 1.6;
margin-top: 1.25rem;
}
.section__note a {
color: rgb(var(--gray));
text-decoration: underline;
text-underline-offset: 2px;
}
.section__note a:hover {
color: rgb(var(--gray-dark));
}
.identity-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.identity-item {
border-top: 1px solid rgb(var(--gray-light));
}
.identity-item:last-child {
border-bottom: 1px solid rgb(var(--gray-light));
}
.identity-link {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.85rem 0;
text-decoration: none;
color: rgb(var(--gray-dark));
transition: color 0.2s;
cursor: pointer;
}
.identity-link:hover {
color: var(--accent);
}
.identity-platform {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgb(var(--gray));
min-width: 90px;
flex-shrink: 0;
transition: color 0.2s;
}
.identity-link:hover .identity-platform {
color: var(--accent);
}
.identity-handle {
font-size: 0.92rem;
font-family: var(--font-maple-mono);
color: inherit;
flex: 1;
}
.identity-arrow {
color: rgb(var(--gray));
flex-shrink: 0;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s, transform 0.2s;
}
.identity-link:hover .identity-arrow {
opacity: 1;
transform: translateX(0);
}
.prose p {
font-size: 1rem;
line-height: 1.75;
color: rgb(var(--gray-dark));
margin-bottom: 0.85rem;
}
.prose p:last-child {
margin-bottom: 0;
}
.prose a {
color: rgb(var(--gray-dark));
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: rgb(var(--gray-light));
transition: text-decoration-color 0.2s;
}
.prose a:hover {
text-decoration-color: var(--accent);
}
.prose em {
font-style: italic;
color: rgb(var(--gray-dark));
}
.tech-list {
list-style: none;
padding: 0;
margin-top: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tech-list li {
font-size: 0.75rem;
font-family: var(--font-maple-mono);
color: rgb(var(--gray));
border: 1px solid rgb(var(--gray-light));
border-radius: 3px;
padding: 0.3rem 0.65rem;
letter-spacing: 0.02em;
}
/* Tablet: stack vertically, cap photo at 400px centered */
@media (max-width: 1023px) {
.hero__inner {
flex-direction: column;
}
.hero__photo-wrap {
width: auto;
max-width: min(100%, 400px);
margin: 2.5rem auto 0;
box-shadow: none;
}
.hero__photo {
width: 100%;
height: auto;
}
.hero__bio {
align-self: center;
max-width: min(100%, 36rem);
padding: 2.5rem 2rem 3rem;
}
.hero__bio-text p {
max-width: none;
}
}
/* Smartphone: tighter paddings, smaller photo */
@media (max-width: 639px) {
.hero__photo-wrap {
max-width: min(100%, 280px);
margin: 2rem auto 0;
}
.hero__bio {
padding: 2rem 1.5rem 2rem;
}
.content {
padding: 2.5rem 0 3rem;
}
.content__inner {
padding: 0 1.5rem;
}
.identity-platform {
min-width: 75px;
}
.tech-list {
gap: 0.4rem;
}
}
</style>

View file

@ -0,0 +1,9 @@
---
interface Props {
schema: Record<string, unknown>;
}
const { schema } = Astro.props;
---
<script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />

View file

@ -1,14 +1,5 @@
---
import AboutHeroImage from '~/assets/placeholder.jpg';
import Layout from '~/layouts/Post.astro';
import AboutPage from '~/components/AboutPage.astro';
---
<Layout
title="About Me"
description="Short introduction."
pubDate={new Date('August 08 2021')}
heroImage={AboutHeroImage}
locale="en"
>
<p>This is the English version of the about page.</p>
</Layout>
<AboutPage locale="en" />

View file

@ -1,14 +1,5 @@
---
import AboutHeroImage from '~/assets/placeholder.jpg';
import Layout from '~/layouts/Post.astro';
import AboutPage from '~/components/AboutPage.astro';
---
<Layout
title="Über mich"
description="Kurze Vorstellung."
pubDate={new Date('August 08 2021')}
heroImage={AboutHeroImage}
locale="de"
>
<p>Hier steht die deutsche Version der Über-mich-Seite.</p>
</Layout>
<AboutPage locale="de" />

View file

@ -50,6 +50,12 @@ main {
body.home main {
width: 1040px;
}
body.about-page main {
width: 960px;
max-width: calc(100% - 2em);
margin: 2em auto;
padding: 0;
}
h1,
h2,
h3,