spike(spa-mini): vanilla-html viewer für einen einzelnen nostr-post

Tech-Spike unter preview/spa-mini/ — eine index.html, lädt
nostr-tools/marked/DOMPurify von esm.sh, holt das kind:30023-Event
mit dtag dezentrale-oep-oer von 5 public-relays, rendert clientseitig.
Beweist, dass die SPA-Architektur in der Praxis funktioniert, ohne
SvelteKit-Build-Pipeline.

Inhalt:
- index.html mit Loader, Renderer, Fehler-Handling
- .htaccess mit SPA-Fallback (relevant sobald gehostet)
- README mit Anleitung lokal/Deploy

.gitignore um .env*, logs/ ergänzt (für späteren Pipeline-Bedarf).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-15 13:53:16 +02:00
parent 4251fee668
commit 0679a335f4
4 changed files with 342 additions and 1 deletions

5
.gitignore vendored
View File

@ -1 +1,4 @@
**/.DS_Store
**/.DS_Store
.env
.env.local
logs/

View File

@ -0,0 +1,13 @@
RewriteEngine On
# HTTPS forcieren (relevant sobald Zertifikat aktiv)
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Existierende Datei oder Verzeichnis? Direkt ausliefern.
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Alles andere → SPA-Fallback (für die Mini-Seite optional, aber harmlos).
RewriteRule ^ /index.html [L]

View File

@ -0,0 +1,58 @@
# SPA Mini-Preview
**Tech-Spike, kein Produkt.**
Eine einzige `index.html`, die im Browser einen einzelnen Nostr-Post (`kind:30023`)
live von Public-Relays lädt und rendert. Beweist, dass die SPA-Architektur
aus [`docs/superpowers/specs/2026-04-15-nostr-page-design.md`](../../docs/superpowers/specs/2026-04-15-nostr-page-design.md)
in der Praxis funktioniert — ohne SvelteKit-Build, ohne Routing, ohne Backend.
## Was sie macht
- Lädt `nostr-tools`, `marked` und `DOMPurify` zur Laufzeit von esm.sh.
- Verbindet sich zu fünf Public-Relays.
- Holt das `kind:30023`-Event mit `d`-Tag `dezentrale-oep-oer` für den hartcodierten Pubkey.
- Rendert Markdown via `marked`, sanitized via `DOMPurify`.
- Cover-Bild wird vom Blossom-Server geladen (URL aus dem Event-Tag `image`).
## Was sie nicht macht
- Kein Routing, keine Post-Liste, keine Tags-Navigation, keine Reactions, keine Kommentare.
- Kein NIP-65-Outbox-Resolution (Relays sind hartcodiert).
- Kein NIP-07-Login.
- Kein Code-Splitting, keine Service-Worker, keine Optimierung.
Für all das wartet die echte SvelteKit-SPA — das hier ist nur das „Hello World".
## Lokal ausprobieren
Die Datei kann nicht per `file://` geöffnet werden (CORS für CDN-Imports).
Stattdessen ein lokaler HTTP-Server:
```sh
cd preview/spa-mini
python3 -m http.server 8000
# Browser: http://localhost:8000/
```
Oder mit Deno:
```sh
deno run --allow-net --allow-read jsr:@std/http/file-server preview/spa-mini
```
## Auf die Subdomain `spa.joerg-lohrer.de` deployen
Voraussetzung: Subdomain im All-Inkl-KAS angelegt, eigener DocumentRoot eingerichtet,
SSL-Zertifikat aktiviert.
Inhalt von `preview/spa-mini/` (also `index.html` und `.htaccess`) per FTP
in den DocumentRoot der Subdomain hochladen.
Erwartetes Ergebnis: `https://spa.joerg-lohrer.de/` zeigt den Post.
## Spätere Ablösung
Sobald die SvelteKit-SPA fertig ist, wird ihr `build/`-Output denselben Webroot
ablösen. Diese Mini-Seite kann dann gelöscht oder als historisches Artefakt
im Repo bleiben.

267
preview/spa-mini/index.html Normal file
View File

@ -0,0 +1,267 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jörg Lohrer — Nostr SPA Mini-Preview</title>
<meta name="robots" content="noindex">
<style>
:root {
--fg: #1f2937;
--muted: #6b7280;
--bg: #fafaf9;
--accent: #2563eb;
--code-bg: #f3f4f6;
--border: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e5e7eb;
--muted: #9ca3af;
--bg: #18181b;
--accent: #60a5fa;
--code-bg: #27272a;
--border: #3f3f46;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font: 17px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--fg);
background: var(--bg);
padding: 1.5rem;
}
main {
max-width: 720px;
margin: 0 auto;
}
header.banner {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.7rem 1rem;
margin-bottom: 2rem;
background: var(--code-bg);
font-size: 0.85rem;
color: var(--muted);
}
header.banner strong { color: var(--fg); }
h1.post-title {
font-size: 2rem;
line-height: 1.2;
margin: 0 0 0.4rem;
}
.meta {
color: var(--muted);
font-size: 0.92rem;
margin-bottom: 2rem;
}
.meta .tags { margin-top: 0.4rem; }
.meta .tag {
display: inline-block;
background: var(--code-bg);
border-radius: 3px;
padding: 1px 7px;
margin-right: 4px;
font-size: 0.85em;
}
article img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
article a { color: var(--accent); }
article pre {
background: var(--code-bg);
padding: 0.8rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.88em;
}
article code {
background: var(--code-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.92em;
}
article pre code { padding: 0; background: none; }
article hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
article blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
color: var(--muted);
margin: 1rem 0;
}
.status {
padding: 1rem;
border-radius: 4px;
background: var(--code-bg);
color: var(--muted);
text-align: center;
}
.error {
background: #fee2e2;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.error { background: #450a0a; color: #fca5a5; }
}
footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.85rem;
}
footer a { color: var(--accent); }
</style>
</head>
<body>
<main>
<header class="banner">
<strong>Tech-Spike:</strong> Diese Seite ist ein Machbarkeitsbeweis,
keine produktive Webseite. Sie lädt einen einzigen, hartcodierten
Nostr-Post live von Public-Relays und rendert ihn im Browser.
Für die volle SPA siehe
<a href="https://github.com/joerglohrer/joerglohrerde">Repo</a>.
</header>
<div id="content">
<p class="status">Lade Post von Nostr-Relays …</p>
</div>
<footer>
Eigenständig signiertes <code>kind:30023</code>-Event (NIP-23),
geladen über <code>nostr-tools</code> via WebSocket aus mehreren Relays.
Markdown-Rendering: <code>marked</code> + <code>DOMPurify</code>.
Bild via Blossom-Server. Kein Server-Backend, nur statisches HTML
plus JavaScript im Browser.
</footer>
</main>
<script type="module">
import { SimplePool } from 'https://esm.sh/nostr-tools@2.10.4/pool';
import { marked } from 'https://esm.sh/marked@14.1.3';
import DOMPurify from 'https://esm.sh/dompurify@3.1.7';
const PUBKEY = '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
const DTAG = 'dezentrale-oep-oer';
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.tchncs.de',
'wss://relay.edufeed.org',
];
const TIMEOUT_MS = 8000;
const $content = document.getElementById('content');
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#39;');
}
function tagValue(event, name) {
const t = event.tags.find(t => t[0] === name);
return t ? t[1] : '';
}
function tagsAll(event, name) {
return event.tags.filter(t => t[0] === name).map(t => t[1]);
}
function fmtDate(unixSeconds) {
const d = new Date(unixSeconds * 1000);
return d.toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric',
});
}
function renderPost(event) {
const title = tagValue(event, 'title') || '(ohne Titel)';
const summary = tagValue(event, 'summary');
const image = tagValue(event, 'image');
const publishedAt = parseInt(tagValue(event, 'published_at') || event.created_at, 10);
const tags = tagsAll(event, 't');
const bodyHtml = DOMPurify.sanitize(marked.parse(event.content || ''), {
ADD_ATTR: ['target', 'rel'],
});
const tagsHtml = tags.length
? `<div class="tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>`
: '';
const coverHtml = image
? `<p><img src="${escapeHtml(image)}" alt="Cover-Bild"></p>`
: '';
const summaryHtml = summary
? `<p style="font-style: italic; color: var(--muted);">${escapeHtml(summary)}</p>`
: '';
document.title = `${title} Jörg Lohrer`;
$content.innerHTML = `
<h1 class="post-title">${escapeHtml(title)}</h1>
<div class="meta">
Veröffentlicht am ${fmtDate(publishedAt)}
${tagsHtml}
</div>
${coverHtml}
${summaryHtml}
<article>${bodyHtml}</article>
`;
// Externe Links automatisch in neuen Tabs öffnen
for (const a of $content.querySelectorAll('article a[href^="http"]')) {
a.target = '_blank';
a.rel = 'noopener';
}
}
function showError(msg) {
$content.innerHTML = `<p class="status error">${escapeHtml(msg)}</p>`;
}
async function loadPost() {
const pool = new SimplePool();
const filter = {
kinds: [30023],
authors: [PUBKEY],
'#d': [DTAG],
limit: 1,
};
try {
const event = await Promise.race([
pool.get(RELAYS, filter),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout — kein Relay hat geantwortet')), TIMEOUT_MS)
),
]);
if (!event) {
showError('Post nicht gefunden auf den abgefragten Relays.');
return;
}
renderPost(event);
} catch (err) {
showError(`Fehler beim Laden: ${err.message}`);
console.error(err);
} finally {
pool.close(RELAYS);
}
}
loadPost();
</script>
</body>
</html>