spa: startseite + archiv + impressum + menü + assets für cutover

Startseite (+page.svelte) komplett überarbeitet:
  - Hero mit lokalem Profilbild (WebP aus static/, schneller als
    kind:0-roundtrip), Begrüßung "Hi Willkommen auf meinem Blog 🤗",
    About/Website aus kind:0
  - Social-Icons-Leiste (Nostr/Mastodon/Bluesky/LinkedIn/ORCID/Mail)
    als inline-SVG, monochrom via currentColor, hover färbt blau
  - Nostr-Icon von satscoffee/nostr_icons (outline, CC0), die anderen
    stilisiert als vereinfachte Brand-Icons
  - Neueste 5 Posts + Archiv-Link

Archiv-Route (/archiv/): alle Posts, nach Jahr gruppiert.

Impressum (/impressum/): static-page, rendert content/impressum.md
(via vite ?raw-import), bleibt aus nostr-feeds draußen. Frontmatter-
parser toleriert trailing-spaces auf --- zeilen.

Menü im Layout: sticky header mit brand + 3 links (Home, Archiv,
Impressum), aktiv-state via akzent-farbe. Footer mit © + Impressum
+ "Nostr-basiert"-hinweis.

Assets: profilbild und favicons aus dem hugo-static (repo-root) nach
app/static/ übernommen, favicon-links in app.html ergänzt.

NIP-05: .well-known/nostr.json in app/static angelegt mit CORS-header
via .htaccess, damit "joerglohrer@joerg-lohrer.de" nach cutover
verifizierbar bleibt.

E2E-Tests angepasst an neue hero/navigation-struktur, 29/29 unit + 4/4
e2e grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-18 15:35:05 +02:00
parent 3f8d3e7592
commit 10e455a078
16 changed files with 523 additions and 19 deletions

View File

@ -10,6 +10,12 @@
<meta property="og:description" content="Jörg Lohrer Blog (Nostr-basiert)" />
<link rel="canonical" href="__SITE_URL__/" />
<meta name="robots" content="index, follow" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/android-chrome-512x512.png" />
<title>Jörg Lohrer</title>
<style>
:root {

View File

@ -0,0 +1,118 @@
<script lang="ts">
// Inline-SVGs als kleine social-icon-leiste. bewusst keine zusätzlichen
// image-requests, kein icon-font. hover-color via currentColor + fill=currentColor.
const AUTHOR_PUBKEY_HEX =
'4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
type Entry = { label: string; href: string; icon: 'nostr' | 'mastodon' | 'linkedin' | 'bluesky' | 'mail' | 'orcid' };
const entries: Entry[] = [
{ label: 'Nostr', href: `https://edufeed.org/p/${AUTHOR_PUBKEY_HEX}`, icon: 'nostr' },
{ label: 'Mastodon', href: 'https://reliverse.social/@joerglohrer', icon: 'mastodon' },
{ label: 'Bluesky', href: 'https://bsky.app/profile/joerglohrer.bsky.social', icon: 'bluesky' },
{ label: 'LinkedIn', href: 'https://www.linkedin.com/in/joerglohrer', icon: 'linkedin' },
{ label: 'ORCID', href: 'https://orcid.org/0000-0002-9282-0406', icon: 'orcid' },
{ label: 'E-Mail', href: 'mailto:webmaster@joerg-lohrer.de', icon: 'mail' }
];
</script>
<nav class="social" aria-label="Soziale Profile und Kontakt">
{#each entries as e (e.href)}
<a
href={e.href}
target={e.icon === 'mail' ? undefined : '_blank'}
rel={e.icon === 'mail' ? undefined : 'me noopener'}
aria-label={e.label}
title={e.label}
>
{#if e.icon === 'nostr'}
<!-- Nostr-Logo (Outline) von satscoffee/nostr_icons, CC0 -->
<svg
viewBox="0 0 875 875"
aria-hidden="true"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="40"
stroke-miterlimit="10"
>
<path
d="m684.72,485.57c.22,12.59-11.93,51.47-38.67,81.3-26.74,29.83-56.02,20.85-58.42,20.16s-3.09-4.46-7.89-3.77-9.6,6.17-18.86,7.2-17.49,1.71-26.06-1.37c-4.46.69-5.14.71-7.2,2.24s-17.83,10.79-21.6,11.47c0,7.2-1.37,44.57,0,55.89s3.77,25.71,7.54,36c3.77,10.29,2.74,10.63,7.54,9.94s13.37.34,15.77,4.11c2.4,3.77,1.37,6.51,5.49,8.23s60.69,17.14,99.43,19.2c26.74.69,42.86,2.74,52.12,19.54,1.37,7.89,7.54,13.03,11.31,14.06s8.23,2.06,12,5.83,1.03,8.23,5.49,11.66c4.46,3.43,14.74,8.57,25.37,13.71,10.63,5.14,15.09,13.37,15.77,16.11s1.71,10.97,1.71,10.97c0,0-8.91,0-10.97-2.06s-2.74-5.83-2.74-5.83c0,0-6.17,1.03-7.54,3.43s.69,2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8,4.11,8.23,5.83,8.23,10.63,10.97s8.23,5.83,8.23,5.83l-7.2,4.46s-4.46,2.06-14.74-.69-11.66-4.46-12.69-10.63,0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77,4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97,0-19.2,3.43-19.89,1.71-26.74-14.06-55.89-19.89-64.12c-13.03,1.03-50.74-.69-50.74-.69,0,0-2.4-.69-17.49,5.83s-36.48,13.76-46.77,19.93-14.4,9.7-16.12,13.13c.12,3-1.23,7.72-2.79,9.06s-12.48,2.42-12.48,2.42c0,0-5.85,5.86-8.25,9.97-6.86,9.6-55.2,125.14-66.52,149.83-13.54,32.57-9.77,27.43-37.71,27.43s-8.06.3-8.06.3c0,0-12.34,5.88-16.8,5.88s-18.86-2.4-26.4,0-16.46,9.26-23.31,10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12,0,0,1.74-6.51,7.91-10.88,8.23-5.83,25.37-16.11,34.63-21.26s17.49-7.89,23.31-9.26,18.51-6.17,30.51-9.94,19.54-8.23,29.83-31.54c10.29-23.31,50.4-111.43,51.43-116.23.63-2.96,3.73-6.48,4.8-15.09.66-5.35-2.49-13.04,1.71-22.63,10.97-25.03,21.6-20.23,26.4-20.23s17.14.34,26.4-1.37,15.43-2.74,24.69-7.89,11.31-8.91,11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2,1.03,8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17,13.03-1.71-18.86-5.14,7.2-2.74-16.11-4.8,8.23-3.43-14.4-5.83,4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46,3.43-4.46,3.43l1.37,12-12.2-6.27-7-11.9s2.36,4.01-9.62,7.53c-20.55,0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89,3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26,25.71-22.97,25.03-16.46,46.29-17.14c21.26-.69,32.91,2.74,46.29,8.23s38.74,13.71,43.89,17.49c11.31-9.94,28.46-19.89,34.29-19.89,1.03-2.4,6.19-12.33,17.96-17.6,35.31-15.81,108.13-34,131.53-35.54,31.2-2.06,7.89-1.37,39.09,2.06,31.2,3.43,54.17,7.54,69.6,12.69,12.58,4.19,25.03,9.6,34.29,2.06,4.33-1.81,11.81-1.34,17.83-5.14,30.69-25.09,34.72-32.35,43.63-41.95s20.14-24.91,22.54-45.14,4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03,2.06-12,5.14-22.29,6.86-30.51s9.94-14.74,19.89-16.46c9.94-1.71,17.83,1.37,22.29,4.8,4.46,3.43,11.65,6.28,13.37,10.29.34,1.71-1.37,6.51,8.23,8.23,9.6,1.71,16.05,4.16,16.05,4.16,0,0,15.64,4.29,3.11,7.73-12.69,2.06-20.52-.71-24.29,1.69s-7.21,10.08-9.61,11.1-7.2.34-12,4.11-9.6,6.86-12.69,14.4-5.49,15.77-3.43,26.74,8.57,31.54,14.4,43.2c5.83,11.66,20.23,40.8,24.34,47.66s15.77,29.49,16.8,53.83,1.03,44.23,0,54.86-10.84,51.65-35.53,85.94c-8.16,14.14-23.21,31.9-24.67,35.03-1.45,3.13-3.02,4.88-1.61,7.65,4.62,9.05,12.87,22.13,14.71,29.22,2.29,6.64,6.99,16.13,7.22,28.72Z"
/>
</svg>
{:else if e.icon === 'mastodon'}
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
<path
fill="currentColor"
d="M21.58 13.9c-.3 1.53-2.67 3.22-5.4 3.54-1.42.17-2.82.33-4.32.26-2.45-.11-4.39-.58-4.39-.58 0 .24.01.47.05.68.32 2.4 2.39 2.55 4.34 2.61 1.97.07 3.74-.49 3.74-.49l.08 1.78s-1.39.75-3.87.88c-1.37.08-3.07-.03-5.05-.56C2.46 20.86 1.72 16.2 1.61 11.47 1.57 10.07 1.59 8.76 1.59 7.66c0-4.85 3.18-6.27 3.18-6.27 1.6-.73 4.35-1.04 7.21-1.07h.07c2.86.02 5.61.33 7.22 1.07 0 0 3.18 1.42 3.18 6.27 0 0 .04 3.59-.45 6.08-.03.16-.18.16-.42.16zm-3.01-5.53c0-1.18-.3-2.11-.9-2.81-.62-.7-1.43-1.05-2.44-1.05-1.17 0-2.06.45-2.65 1.35l-.58.97-.58-.97c-.59-.9-1.48-1.35-2.65-1.35-1.01 0-1.82.36-2.44 1.05-.6.7-.9 1.63-.9 2.81v5.78h2.3V8.54c0-1.18.5-1.78 1.5-1.78 1.1 0 1.65.71 1.65 2.13v3.09h2.28V8.88c0-1.41.55-2.13 1.65-2.13 1 0 1.5.6 1.5 1.78v5.61h2.3V8.37z"
/>
</svg>
{:else if e.icon === 'bluesky'}
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
<path
fill="currentColor"
d="M5.2 3.5c2.4 1.8 5 5.4 5.9 7.4.9-2 3.5-5.6 5.9-7.4 1.8-1.3 4.7-2.3 4.7 1 0 .7-.4 5.6-.6 6.4-.8 2.8-3.6 3.5-6.1 3.1 4.4.7 5.5 3.2 3.1 5.7-4.6 4.8-6.6-1.2-7.1-2.7-.1-.3-.1-.4-.1-.3 0-.1-.1 0-.1.3-.5 1.5-2.5 7.5-7.1 2.7-2.5-2.5-1.3-5 3.1-5.7-2.5.4-5.3-.3-6.1-3.1C-.1 10.1-.5 5.2-.5 4.5c0-3.3 2.9-2.3 4.7-1l1 0z"
/>
</svg>
{:else if e.icon === 'linkedin'}
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
<path
fill="currentColor"
d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14zM8.34 18.34v-8.4H5.67v8.4h2.67zM7 8.67a1.55 1.55 0 1 0 0-3.1 1.55 1.55 0 0 0 0 3.1zm11.34 9.67v-4.6c0-2.46-1.31-3.6-3.06-3.6-1.41 0-2.04.78-2.39 1.32v-1.13h-2.67c.04.75 0 8.01 0 8.01h2.67v-4.47c0-.24.02-.48.09-.65.19-.48.63-.97 1.37-.97.97 0 1.36.74 1.36 1.82v4.27h2.63z"
/>
</svg>
{:else if e.icon === 'orcid'}
<!-- offizielles ORCID-symbol stark vereinfacht, monochrom via currentColor -->
<svg viewBox="0 0 256 256" aria-hidden="true" width="20" height="20">
<circle cx="128" cy="128" r="128" fill="currentColor" opacity="0.15" />
<circle cx="128" cy="128" r="118" fill="none" stroke="currentColor" stroke-width="10" />
<path
fill="currentColor"
d="M86 76a8 8 0 1 1-16 0 8 8 0 0 1 16 0zM72 96h14v96H72V96zm34 0h38c28 0 50 21 50 48s-22 48-50 48h-38V96zm14 14v68h22c22 0 36-14 36-34s-14-34-36-34h-22z"
/>
</svg>
{:else if e.icon === 'mail'}
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
<path
fill="currentColor"
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm0 2v.01L12 13l8-6.99V6H4zm0 2.7V18h16V8.7l-8 6.99L4 8.7z"
/>
</svg>
{/if}
</a>
{/each}
</nav>
<style>
.social {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.6rem;
}
.social a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--code-bg);
color: var(--muted);
transition:
color 140ms,
background 140ms,
transform 140ms;
}
.social a:hover,
.social a:focus-visible {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, var(--code-bg));
transform: translateY(-1px);
}
.social svg {
display: block;
}
</style>

View File

@ -1,32 +1,135 @@
<script lang="ts">
import { onMount } from 'svelte';
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/state';
import { bootstrapReadRelays } from '$lib/stores/readRelays';
let { children } = $props();
// Normalisierter pfad ohne trailing slash für aktiv-erkennung ("/" bleibt "/")
const currentPath = $derived((page.url?.pathname ?? '/').replace(/\/$/, '') || '/');
function isActive(path: string): boolean {
const normalized = path.replace(/\/$/, '') || '/';
return currentPath === normalized;
}
onMount(() => {
bootstrapReadRelays();
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<!-- favicon-Tags liegen in src/app.html — hier nichts nötig. -->
<header class="site-header">
<div class="header-inner">
<a href="/" class="brand" aria-label="Zur Startseite">Jörg Lohrer</a>
<nav aria-label="Hauptnavigation">
<a href="/" class:active={isActive('/')}>Home</a>
<a href="/archiv/" class:active={isActive('/archiv/')}>Archiv</a>
<a href="/impressum/" class:active={isActive('/impressum/')}>Impressum</a>
</nav>
</div>
</header>
<main>
{@render children()}
</main>
<footer class="site-footer">
<div class="footer-inner">
<span class="footer-copy">© Jörg Lohrer</span>
<span class="footer-sep">·</span>
<a href="/impressum/">Impressum</a>
<span class="footer-sep">·</span>
<span class="footer-meta">Nostr-basiert</span>
</div>
</footer>
<style>
.site-header {
border-bottom: 1px solid var(--border);
background: var(--bg);
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(8px);
}
.header-inner {
max-width: 720px;
margin: 0 auto;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
font-weight: 700;
font-size: 1.05rem;
color: var(--fg);
text-decoration: none;
white-space: nowrap;
}
nav {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
nav a {
color: var(--muted);
text-decoration: none;
font-size: 0.95rem;
padding: 0.25rem 0;
border-bottom: 2px solid transparent;
transition: color 120ms, border-color 120ms;
}
nav a:hover {
color: var(--fg);
}
nav a.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
main {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem 1rem;
min-height: calc(100vh - 200px);
}
@media (min-width: 640px) {
main {
padding: 1.5rem;
}
}
.site-footer {
border-top: 1px solid var(--border);
margin-top: 3rem;
}
.footer-inner {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
color: var(--muted);
font-size: 0.85rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
.footer-inner a {
color: var(--muted);
text-decoration: none;
}
.footer-inner a:hover {
color: var(--accent);
text-decoration: underline;
}
.footer-sep {
opacity: 0.5;
}
.footer-meta {
opacity: 0.7;
}
</style>

View File

@ -4,9 +4,14 @@
import { loadPostList } from '$lib/nostr/loaders';
import { getProfile } from '$lib/nostr/profileCache';
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
import ProfileCard from '$lib/components/ProfileCard.svelte';
import PostCard from '$lib/components/PostCard.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
import SocialIcons from '$lib/components/SocialIcons.svelte';
// Lokales Profilbild aus static/ — schneller als der Nostr-kind:0-Roundtrip
// fürs kind:0 -> picture-Feld (URL wäre identisch, aber Netzwerk-Latenz).
const HERO_AVATAR = '/joerg-profil-2024.webp';
const LATEST_COUNT = 5;
let profile: Profile | null = $state(null);
let posts: NostrEvent[] = $state([]);
@ -29,24 +34,141 @@
});
$effect(() => {
const name = profile?.display_name ?? profile?.name ?? 'Jörg Lohrer';
const p = profile;
const name = (p && (p.display_name ?? p.name)) ?? 'Jörg Lohrer';
document.title = `${name} Blog`;
});
const displayName = $derived.by(() => {
const p = profile;
return (p && (p.display_name ?? p.name)) ?? 'Jörg Lohrer';
});
const avatarSrc = HERO_AVATAR;
const about = $derived.by(() => profile?.about ?? '');
const website = $derived.by(() => profile?.website ?? '');
const latest = $derived(posts.slice(0, LATEST_COUNT));
const hasMore = $derived(posts.length > LATEST_COUNT);
</script>
<ProfileCard {profile} />
<section class="hero">
<div class="hero-left">
<img class="avatar" src={avatarSrc} alt={displayName} />
<SocialIcons />
</div>
<div class="hero-text">
<h1 class="hero-name">{displayName}</h1>
<p class="hero-greeting">
Hi <span aria-hidden="true">🖖</span> Willkommen auf meinem Blog
<span aria-hidden="true">🤗</span>
</p>
{#if about}
<p class="hero-about">{about}</p>
{/if}
{#if website}
<div class="meta-line">
<a href={website} target="_blank" rel="noopener">
{website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
</div>
{/if}
</div>
</section>
<h1 class="list-title">Beiträge</h1>
<LoadingOrError {loading} {error} />
{#each posts as post (post.id)}
<PostCard event={post} />
{/each}
<section class="latest">
<h2 class="section-title">Neueste Beiträge</h2>
<LoadingOrError {loading} {error} />
{#each latest as post (post.id)}
<PostCard event={post} />
{/each}
{#if hasMore}
<div class="more">
<a href="/archiv/" class="more-link">Alle Beiträge im Archiv →</a>
</div>
{/if}
</section>
<style>
.list-title {
.hero {
display: flex;
gap: 1.25rem;
align-items: flex-start;
padding: 1rem 0 2rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.hero-left {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.avatar {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
background: var(--code-bg);
border: 2px solid var(--accent);
}
.hero-text {
flex: 1;
min-width: 0;
}
.hero-name {
margin: 0 0 0.3rem;
font-size: 1.6rem;
font-weight: 700;
}
.hero-greeting {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--fg);
}
.hero-about {
margin: 0 0 0.5rem;
color: var(--muted);
font-size: 1rem;
line-height: 1.45;
}
.meta-line {
font-size: 0.9rem;
color: var(--muted);
}
.meta-line a {
color: var(--accent);
text-decoration: none;
}
.meta-line a:hover {
text-decoration: underline;
}
.section-title {
margin: 0 0 1rem;
font-size: 1.4rem;
font-size: 1.25rem;
font-weight: 600;
}
.more {
margin-top: 1.5rem;
text-align: center;
}
.more-link {
color: var(--accent);
text-decoration: none;
font-weight: 500;
}
.more-link:hover {
text-decoration: underline;
}
@media (max-width: 520px) {
.hero {
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.8rem;
}
.hero-left {
align-items: center;
}
}
</style>

View File

@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent } from '$lib/nostr/loaders';
import { loadPostList } from '$lib/nostr/loaders';
import PostCard from '$lib/components/PostCard.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
let posts: NostrEvent[] = $state([]);
let loading = $state(true);
let error: string | null = $state(null);
onMount(async () => {
try {
posts = await loadPostList();
loading = false;
if (posts.length === 0) {
error = 'Keine Posts gefunden auf den abgefragten Relays.';
}
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
}
});
// Posts nach Jahr gruppieren (neueste zuerst)
type YearGroup = { year: number; posts: NostrEvent[] };
const groupsByYear = $derived.by<YearGroup[]>(() => {
const byYear = new Map<number, NostrEvent[]>();
for (const p of posts) {
const ts = Number(p.tags.find((t) => t[0] === 'published_at')?.[1] ?? p.created_at);
const year = new Date(ts * 1000).getUTCFullYear();
if (!byYear.has(year)) byYear.set(year, []);
byYear.get(year)!.push(p);
}
return [...byYear.entries()]
.map(([year, p]) => ({ year, posts: p }))
.sort((a, b) => b.year - a.year);
});
</script>
<svelte:head>
<title>Archiv Jörg Lohrer</title>
</svelte:head>
<h1 class="title">Archiv</h1>
<p class="meta">Alle Beiträge, nach Jahr gruppiert.</p>
<LoadingOrError {loading} {error} />
{#each groupsByYear as group (group.year)}
<section class="year-group">
<h2 class="year">{group.year}</h2>
{#each group.posts as post (post.id)}
<PostCard event={post} />
{/each}
</section>
{/each}
<style>
.title {
margin: 0 0 0.3rem;
font-size: 1.8rem;
}
.meta {
color: var(--muted);
margin: 0 0 2rem;
font-size: 0.95rem;
}
.year-group {
margin-bottom: 2.5rem;
}
.year {
font-size: 1.2rem;
font-weight: 600;
margin: 0 0 1rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
}
</style>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import { renderMarkdown } from '$lib/render/markdown';
import impressumRaw from '../../../../content/impressum.md?raw';
// Frontmatter abtrennen, nur Body rendern.
// Toleriert trailing spaces auf den ---/--- trenner-zeilen.
const match = impressumRaw.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n([\s\S]*)$/);
const body = match ? match[1] : impressumRaw;
const html = renderMarkdown(body);
</script>
<svelte:head>
<title>Impressum Jörg Lohrer</title>
<meta name="robots" content="index, follow" />
</svelte:head>
<article class="impressum">
{@html html}
</article>
<style>
.impressum :global(h1) {
font-size: 1.8rem;
margin: 0 0 1rem;
}
.impressum :global(h2) {
font-size: 1.3rem;
margin: 2rem 0 0.6rem;
}
.impressum :global(h3) {
font-size: 1.05rem;
margin: 1.4rem 0 0.4rem;
}
.impressum :global(p) {
margin: 0 0 1rem;
}
.impressum :global(a) {
color: var(--accent);
}
</style>

View File

@ -4,6 +4,13 @@ RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# NIP-05-Verifikation: CORS-Header für .well-known/nostr.json, sonst
# lehnen nostr-clients die verifizierung ab.
<FilesMatch "nostr\.json$">
Header set Access-Control-Allow-Origin "*"
Header set Content-Type "application/json"
</FilesMatch>
# Existierende Datei oder Verzeichnis? Direkt ausliefern.
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d

View File

@ -0,0 +1,14 @@
{
"names": {
"joerglohrer": "4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41"
},
"relays": {
"4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41": [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://relay.tchncs.de",
"wss://relay.edufeed.org"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
app/static/apple-touch-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
app/static/favicon-16x16.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

BIN
app/static/favicon-32x32.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
app/static/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,8 +1,22 @@
import { expect, test } from '@playwright/test';
test('Home zeigt Profil und mindestens einen Post', async ({ page }) => {
test('Home zeigt Hero (Name + Avatar) und neueste Beiträge', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1, name: /Beiträge/ })).toBeVisible();
await expect(page.locator('.profile .name')).toBeVisible({ timeout: 15_000 });
// Hero: Name als h1
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
// Hero: Avatar (lokaler fallback oder nostr-profil)
await expect(page.locator('.hero .avatar')).toBeVisible({ timeout: 15_000 });
// Neueste-Beiträge-Sektion
await expect(page.getByRole('heading', { level: 2, name: /Neueste Beiträge/i })).toBeVisible();
// Mindestens ein Post lädt
await expect(page.locator('a.card').first()).toBeVisible({ timeout: 15_000 });
});
test('Navigation erreicht Archiv und Impressum', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Archiv', exact: true }).click();
await expect(page.getByRole('heading', { level: 1, name: /Archiv/i })).toBeVisible();
await page.getByRole('link', { name: 'Impressum', exact: true }).first().click();
await expect(page.getByRole('heading', { level: 1, name: /Impressum/i })).toBeVisible();
});