spike(spa-mini): url-routing, post-liste mit thumbnails, streaming-load
Liest dtag aus URL-Pfad (SPA-Navigation via History-API) und zeigt Liste auf /, Einzelpost auf /<dtag>/. Interne Links ohne Reload, Browser-Back funktioniert. Streaming-Load via pool.subscribeMany: Events werden angezeigt, sobald das erste Relay antwortet, statt auf alle 5 zu warten. Deutlich bessere Reaktionszeit. Liste mit Cover-Thumbnail links, Titel+Summary+Datum rechts. Responsive: unter 480px stapelt sich Bild über Text. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1ae6445c84
commit
f070ea33c0
|
|
@ -39,20 +39,63 @@
|
|||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
header.banner,
|
||||
.intro {
|
||||
header.banner {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--code-bg);
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.intro {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
header.banner strong { color: var(--fg); }
|
||||
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;
|
||||
}
|
||||
h1.post-title {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.25;
|
||||
|
|
@ -156,20 +199,19 @@
|
|||
<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.
|
||||
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.
|
||||
</header>
|
||||
|
||||
<div class="intro">
|
||||
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.
|
||||
</div>
|
||||
<nav id="breadcrumb" hidden>
|
||||
<a href="/" data-link>← Zurück zur Übersicht</a>
|
||||
</nav>
|
||||
|
||||
<div id="content">
|
||||
<p class="status">Lade Post von Nostr-Relays …</p>
|
||||
<p class="status">Lade von Nostr-Relays …</p>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
|
|
@ -183,7 +225,6 @@
|
|||
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',
|
||||
|
|
@ -194,6 +235,8 @@
|
|||
const TIMEOUT_MS = 8000;
|
||||
|
||||
const $content = document.getElementById('content');
|
||||
const $breadcrumb = document.getElementById('breadcrumb');
|
||||
const pool = new SimplePool();
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
|
|
@ -241,6 +284,7 @@
|
|||
: '';
|
||||
|
||||
document.title = `${title} – Jörg Lohrer`;
|
||||
$breadcrumb.hidden = false;
|
||||
|
||||
$content.innerHTML = `
|
||||
<h1 class="post-title">${escapeHtml(title)}</h1>
|
||||
|
|
@ -260,42 +304,195 @@
|
|||
}
|
||||
}
|
||||
|
||||
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}
|
||||
`;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
$breadcrumb.hidden = true;
|
||||
$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,
|
||||
};
|
||||
function showLoading(msg) {
|
||||
$content.innerHTML = `<p class="status">${escapeHtml(msg)}</p>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await Promise.race([
|
||||
pool.get(RELAYS, filter),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout — kein Relay hat geantwortet')), TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
function withTimeout(promise, ms, errMsg) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(errMsg)), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
showError('Post nicht gefunden auf den abgefragten Relays.');
|
||||
return;
|
||||
}
|
||||
let activeSub = null;
|
||||
|
||||
renderPost(event);
|
||||
} catch (err) {
|
||||
showError(`Fehler beim Laden: ${err.message}`);
|
||||
console.error(err);
|
||||
} finally {
|
||||
pool.close(RELAYS);
|
||||
function cancelActiveSub() {
|
||||
if (activeSub) {
|
||||
try { activeSub.close(); } catch {}
|
||||
activeSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
loadPost();
|
||||
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
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
cancelActiveSub();
|
||||
if (!byDtag.size) {
|
||||
showError('Timeout — kein Relay hat geantwortet.');
|
||||
} else {
|
||||
renderList([...byDtag.values()]);
|
||||
}
|
||||
}
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
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);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// 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();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue