2026-04-15 13:53:16 +02:00
|
|
|
|
<!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);
|
2026-04-15 14:03:10 +02:00
|
|
|
|
padding: 1.5rem 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (min-width: 640px) {
|
|
|
|
|
|
body { padding: 1.5rem; }
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
main {
|
|
|
|
|
|
max-width: 720px;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
}
|
2026-04-15 14:21:34 +02:00
|
|
|
|
header.banner {
|
2026-04-15 13:53:16 +02:00
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
padding: 0.7rem 1rem;
|
2026-04-15 14:21:34 +02:00
|
|
|
|
margin-bottom: 1.5rem;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
background: var(--code-bg);
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
header.banner strong { color: var(--fg); }
|
2026-04-15 14:21:34 +02:00
|
|
|
|
nav#breadcrumb {
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
nav#breadcrumb a { color: var(--accent); text-decoration: none; }
|
|
|
|
|
|
nav#breadcrumb a:hover { text-decoration: underline; }
|
|
|
|
|
|
.post-list-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
padding: 1rem 0;
|
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
.post-list-item:hover { background: var(--code-bg); }
|
|
|
|
|
|
.post-list-item .thumb {
|
|
|
|
|
|
flex: 0 0 120px;
|
|
|
|
|
|
aspect-ratio: 1 / 1;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
background: var(--code-bg) center/cover no-repeat;
|
|
|
|
|
|
}
|
|
|
|
|
|
.post-list-item .text { flex: 1; min-width: 0; }
|
|
|
|
|
|
.post-list-item h2 {
|
|
|
|
|
|
margin: 0 0 0.3rem;
|
|
|
|
|
|
font-size: 1.2rem;
|
|
|
|
|
|
color: var(--fg);
|
|
|
|
|
|
word-wrap: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
.post-list-item .excerpt {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.post-list-item .list-meta {
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
margin-bottom: 0.2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 479px) {
|
|
|
|
|
|
.post-list-item { flex-direction: column; gap: 0.5rem; }
|
|
|
|
|
|
.post-list-item .thumb { flex: 0 0 auto; width: 100%; aspect-ratio: 2 / 1; }
|
|
|
|
|
|
}
|
|
|
|
|
|
.list-title {
|
|
|
|
|
|
margin: 0 0 1rem;
|
|
|
|
|
|
font-size: 1.4rem;
|
|
|
|
|
|
}
|
2026-04-15 13:53:16 +02:00
|
|
|
|
h1.post-title {
|
2026-04-15 14:03:10 +02:00
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
|
line-height: 1.25;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
margin: 0 0 0.4rem;
|
2026-04-15 14:03:10 +02:00
|
|
|
|
word-wrap: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (min-width: 640px) {
|
|
|
|
|
|
h1.post-title { font-size: 2rem; line-height: 1.2; }
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
.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;
|
2026-04-15 14:03:10 +02:00
|
|
|
|
margin: 0 4px 4px 0;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
font-size: 0.85em;
|
|
|
|
|
|
}
|
2026-04-15 14:03:10 +02:00
|
|
|
|
article {
|
|
|
|
|
|
word-wrap: break-word;
|
|
|
|
|
|
overflow-wrap: break-word;
|
|
|
|
|
|
}
|
2026-04-15 14:29:57 +02:00
|
|
|
|
article img,
|
|
|
|
|
|
#content > p > img {
|
2026-04-15 13:53:16 +02:00
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
height: auto;
|
|
|
|
|
|
border-radius: 4px;
|
2026-04-15 14:29:57 +02:00
|
|
|
|
display: block;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Cover-Bild (direktes <p> als Sibling unter .meta) auf vernünftige Größe begrenzen */
|
|
|
|
|
|
#content > p:has(> img) {
|
|
|
|
|
|
max-width: 480px;
|
|
|
|
|
|
margin: 1rem auto 1.5rem;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
2026-04-15 14:03:10 +02:00
|
|
|
|
article a {
|
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
2026-04-15 13:53:16 +02:00
|
|
|
|
article pre {
|
|
|
|
|
|
background: var(--code-bg);
|
|
|
|
|
|
padding: 0.8rem;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
font-size: 0.88em;
|
2026-04-15 14:03:10 +02:00
|
|
|
|
max-width: 100%;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
article code {
|
|
|
|
|
|
background: var(--code-bg);
|
|
|
|
|
|
padding: 1px 4px;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
font-size: 0.92em;
|
2026-04-15 14:03:10 +02:00
|
|
|
|
word-break: break-word;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
2026-04-15 14:03:10 +02:00
|
|
|
|
article pre code { padding: 0; background: none; word-break: normal; }
|
2026-04-15 13:53:16 +02:00
|
|
|
|
article hr {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
|
|
margin: 2rem 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
article blockquote {
|
|
|
|
|
|
border-left: 3px solid var(--border);
|
2026-04-15 14:03:10 +02:00
|
|
|
|
padding: 0 0 0 1rem;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
margin: 1rem 0;
|
2026-04-15 14:03:10 +02:00
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
article h1, article h2, article h3, article h4 {
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
word-wrap: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
article ul, article ol { padding-left: 1.5rem; }
|
|
|
|
|
|
article table {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
border-collapse: collapse;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
.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;
|
2026-04-15 14:03:10 +02:00
|
|
|
|
text-align: center;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
footer a { color: var(--accent); }
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<main>
|
|
|
|
|
|
<header class="banner">
|
|
|
|
|
|
<strong>Tech-Spike:</strong> Diese Seite ist ein Machbarkeitsbeweis,
|
2026-04-15 14:21:34 +02:00
|
|
|
|
keine produktive Webseite. Sie lädt Nostr-Events
|
|
|
|
|
|
(<code>kind:30023</code>, NIP-23) live von Public-Relays und rendert
|
|
|
|
|
|
sie im Browser. Kein Server-Backend, nur statisches HTML plus
|
|
|
|
|
|
JavaScript. Routing via URL-Pfad: <code>/</code> zeigt die Liste,
|
|
|
|
|
|
<code>/<slug>/</code> zeigt einen einzelnen Post.
|
2026-04-15 13:53:16 +02:00
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-04-15 14:21:34 +02:00
|
|
|
|
<nav id="breadcrumb" hidden>
|
|
|
|
|
|
<a href="/" data-link>← Zurück zur Übersicht</a>
|
|
|
|
|
|
</nav>
|
2026-04-15 14:03:10 +02:00
|
|
|
|
|
|
|
|
|
|
<div id="content">
|
2026-04-15 14:21:34 +02:00
|
|
|
|
<p class="status">Lade von Nostr-Relays …</p>
|
2026-04-15 14:03:10 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<footer>
|
|
|
|
|
|
<a href="https://forgejo.joerglohrer.synology.me/joerglohrer/joerglohrerde">Quellcode & Spezifikation</a>
|
2026-04-15 13:53:16 +02:00
|
|
|
|
</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 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');
|
2026-04-15 14:21:34 +02:00
|
|
|
|
const $breadcrumb = document.getElementById('breadcrumb');
|
|
|
|
|
|
const pool = new SimplePool();
|
2026-04-15 13:53:16 +02:00
|
|
|
|
|
|
|
|
|
|
function escapeHtml(s) {
|
|
|
|
|
|
return String(s)
|
|
|
|
|
|
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
|
|
|
|
.replaceAll('"', '"').replaceAll("'", ''');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function tagValue(event, name) {
|
|
|
|
|
|
const t = event.tags.find(t => t[0] === name);
|
|
|
|
|
|
return t ? t[1] : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function tagsAll(event, name) {
|
2026-04-15 14:29:57 +02:00
|
|
|
|
// Dedup, falls ein Client doppelte Tags geschrieben hat
|
|
|
|
|
|
return [...new Set(event.tags.filter(t => t[0] === name).map(t => t[1]))];
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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`;
|
2026-04-15 14:21:34 +02:00
|
|
|
|
$breadcrumb.hidden = false;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
|
|
|
|
|
|
$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';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 14:21:34 +02:00
|
|
|
|
function renderList(events) {
|
|
|
|
|
|
document.title = 'Jörg Lohrer – Blog';
|
|
|
|
|
|
$breadcrumb.hidden = true;
|
|
|
|
|
|
|
|
|
|
|
|
if (!events.length) {
|
|
|
|
|
|
$content.innerHTML = '<p class="status">Keine Posts gefunden.</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Dedup per d-Tag: neueste Version pro d wins (replaceable-Semantik)
|
|
|
|
|
|
const byDtag = new Map();
|
|
|
|
|
|
for (const ev of events) {
|
|
|
|
|
|
const d = tagValue(ev, 'd');
|
|
|
|
|
|
if (!d) continue;
|
|
|
|
|
|
const existing = byDtag.get(d);
|
|
|
|
|
|
if (!existing || ev.created_at > existing.created_at) {
|
|
|
|
|
|
byDtag.set(d, ev);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sorted = [...byDtag.values()].sort((a, b) => {
|
|
|
|
|
|
const aP = parseInt(tagValue(a, 'published_at') || a.created_at, 10);
|
|
|
|
|
|
const bP = parseInt(tagValue(b, 'published_at') || b.created_at, 10);
|
|
|
|
|
|
return bP - aP;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const itemsHtml = sorted.map(ev => {
|
|
|
|
|
|
const dtag = tagValue(ev, 'd');
|
|
|
|
|
|
const title = tagValue(ev, 'title') || '(ohne Titel)';
|
|
|
|
|
|
const summary = tagValue(ev, 'summary');
|
|
|
|
|
|
const image = tagValue(ev, 'image');
|
|
|
|
|
|
const publishedAt = parseInt(tagValue(ev, 'published_at') || ev.created_at, 10);
|
|
|
|
|
|
const thumbStyle = image ? `style="background-image:url('${escapeHtml(image)}')"` : '';
|
|
|
|
|
|
return `
|
|
|
|
|
|
<a class="post-list-item" href="/${encodeURIComponent(dtag)}/" data-link>
|
|
|
|
|
|
<div class="thumb" ${thumbStyle} aria-hidden="true"></div>
|
|
|
|
|
|
<div class="text">
|
|
|
|
|
|
<div class="list-meta">${fmtDate(publishedAt)}</div>
|
|
|
|
|
|
<h2>${escapeHtml(title)}</h2>
|
|
|
|
|
|
${summary ? `<p class="excerpt">${escapeHtml(summary)}</p>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
$content.innerHTML = `
|
|
|
|
|
|
<h1 class="list-title">Beiträge</h1>
|
|
|
|
|
|
${itemsHtml}
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 13:53:16 +02:00
|
|
|
|
function showError(msg) {
|
2026-04-15 14:21:34 +02:00
|
|
|
|
$breadcrumb.hidden = true;
|
2026-04-15 13:53:16 +02:00
|
|
|
|
$content.innerHTML = `<p class="status error">${escapeHtml(msg)}</p>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 14:21:34 +02:00
|
|
|
|
function showLoading(msg) {
|
|
|
|
|
|
$content.innerHTML = `<p class="status">${escapeHtml(msg)}</p>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function withTimeout(promise, ms, errMsg) {
|
|
|
|
|
|
return Promise.race([
|
|
|
|
|
|
promise,
|
|
|
|
|
|
new Promise((_, reject) =>
|
|
|
|
|
|
setTimeout(() => reject(new Error(errMsg)), ms)
|
|
|
|
|
|
),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let activeSub = null;
|
|
|
|
|
|
|
|
|
|
|
|
function cancelActiveSub() {
|
|
|
|
|
|
if (activeSub) {
|
|
|
|
|
|
try { activeSub.close(); } catch {}
|
|
|
|
|
|
activeSub = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadPost(dtag) {
|
|
|
|
|
|
cancelActiveSub();
|
|
|
|
|
|
showLoading('Lade Post …');
|
|
|
|
|
|
let rendered = false;
|
|
|
|
|
|
let bestEvent = null;
|
|
|
|
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
|
if (!rendered) {
|
|
|
|
|
|
cancelActiveSub();
|
|
|
|
|
|
showError('Timeout — kein Relay hat geantwortet.');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, TIMEOUT_MS);
|
|
|
|
|
|
|
|
|
|
|
|
activeSub = pool.subscribeMany(RELAYS, [
|
|
|
|
|
|
{ kinds: [30023], authors: [PUBKEY], '#d': [dtag], limit: 1 }
|
|
|
|
|
|
], {
|
|
|
|
|
|
onevent(ev) {
|
|
|
|
|
|
// Replaceable: neueste Version wins
|
|
|
|
|
|
if (!bestEvent || ev.created_at > bestEvent.created_at) {
|
|
|
|
|
|
bestEvent = ev;
|
|
|
|
|
|
rendered = true;
|
|
|
|
|
|
renderPost(ev);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
oneose() {
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
if (!rendered) {
|
|
|
|
|
|
cancelActiveSub();
|
|
|
|
|
|
showError('Post nicht gefunden auf den abgefragten Relays.');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadList() {
|
|
|
|
|
|
cancelActiveSub();
|
|
|
|
|
|
showLoading('Lade Beitragsliste …');
|
|
|
|
|
|
const byDtag = new Map();
|
|
|
|
|
|
let renderTimer = null;
|
|
|
|
|
|
let done = false;
|
|
|
|
|
|
|
|
|
|
|
|
const scheduleRender = () => {
|
|
|
|
|
|
if (renderTimer) return;
|
|
|
|
|
|
renderTimer = setTimeout(() => {
|
|
|
|
|
|
renderTimer = null;
|
|
|
|
|
|
renderList([...byDtag.values()]);
|
|
|
|
|
|
}, 100); // coalesce rapid inflow
|
2026-04-15 13:53:16 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-15 14:21:34 +02:00
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
|
if (!done) {
|
|
|
|
|
|
done = true;
|
|
|
|
|
|
cancelActiveSub();
|
|
|
|
|
|
if (!byDtag.size) {
|
|
|
|
|
|
showError('Timeout — kein Relay hat geantwortet.');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
renderList([...byDtag.values()]);
|
|
|
|
|
|
}
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
2026-04-15 14:21:34 +02:00
|
|
|
|
}, TIMEOUT_MS);
|
2026-04-15 13:53:16 +02:00
|
|
|
|
|
2026-04-15 14:21:34 +02:00
|
|
|
|
activeSub = pool.subscribeMany(RELAYS, [
|
|
|
|
|
|
{ kinds: [30023], authors: [PUBKEY], limit: 200 }
|
|
|
|
|
|
], {
|
|
|
|
|
|
onevent(ev) {
|
|
|
|
|
|
const d = ev.tags.find(t => t[0] === 'd')?.[1];
|
|
|
|
|
|
if (!d) return;
|
|
|
|
|
|
const existing = byDtag.get(d);
|
|
|
|
|
|
if (!existing || ev.created_at > existing.created_at) {
|
|
|
|
|
|
byDtag.set(d, ev);
|
|
|
|
|
|
scheduleRender();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
oneose() {
|
|
|
|
|
|
if (done) return;
|
|
|
|
|
|
done = true;
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
|
|
|
|
|
|
renderList([...byDtag.values()]);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function route() {
|
|
|
|
|
|
// Pfad: "" oder "/" → Liste; "/<dtag>" oder "/<dtag>/" → Einzelpost
|
|
|
|
|
|
const path = location.pathname.replace(/^\/+|\/+$/g, '');
|
|
|
|
|
|
if (path === '' || path === 'index.html') {
|
|
|
|
|
|
loadList();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const dtag = decodeURIComponent(path.split('/')[0]);
|
|
|
|
|
|
loadPost(dtag);
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
2026-04-15 14:21:34 +02:00
|
|
|
|
window.scrollTo(0, 0);
|
2026-04-15 13:53:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 14:21:34 +02:00
|
|
|
|
// SPA-Navigation: interne Links (data-link) ohne Page-Reload
|
|
|
|
|
|
document.addEventListener('click', ev => {
|
|
|
|
|
|
const link = ev.target.closest('a[data-link]');
|
|
|
|
|
|
if (!link) return;
|
|
|
|
|
|
const href = link.getAttribute('href');
|
|
|
|
|
|
if (!href || href.startsWith('http') || href.startsWith('#')) return;
|
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
|
if (location.pathname !== href) {
|
|
|
|
|
|
history.pushState(null, '', href);
|
|
|
|
|
|
route();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('popstate', route);
|
|
|
|
|
|
|
|
|
|
|
|
route();
|
2026-04-15 13:53:16 +02:00
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|