joerglohrerde/preview/spa-mini/index.html

642 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 1rem;
}
@media (min-width: 640px) {
body { 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: 1.5rem;
background: var(--code-bg);
font-size: 0.85rem;
color: var(--muted);
}
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;
}
.profile {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.profile .avatar {
flex: 0 0 80px;
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
background: var(--code-bg);
}
.profile .info { flex: 1; min-width: 0; }
.profile .name {
font-size: 1.3rem;
font-weight: 600;
margin: 0 0 0.2rem;
}
.profile .about {
color: var(--muted);
font-size: 0.95rem;
margin: 0 0 0.3rem;
}
.profile .meta-line {
font-size: 0.85rem;
color: var(--muted);
}
.profile .meta-line a {
color: var(--accent);
text-decoration: none;
}
.profile .meta-line a:hover { text-decoration: underline; }
.profile .meta-line .sep { margin: 0 0.4rem; opacity: 0.5; }
h1.post-title {
font-size: 1.5rem;
line-height: 1.25;
margin: 0 0 0.4rem;
word-wrap: break-word;
}
@media (min-width: 640px) {
h1.post-title { font-size: 2rem; line-height: 1.2; }
}
.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: 0 4px 4px 0;
font-size: 0.85em;
}
article {
word-wrap: break-word;
overflow-wrap: break-word;
}
article img,
#content > p > img {
max-width: 100%;
height: auto;
border-radius: 4px;
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;
}
article a {
color: var(--accent);
word-break: break-word;
}
article pre {
background: var(--code-bg);
padding: 0.8rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.88em;
max-width: 100%;
}
article code {
background: var(--code-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.92em;
word-break: break-word;
}
article pre code { padding: 0; background: none; word-break: normal; }
article hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
article blockquote {
border-left: 3px solid var(--border);
padding: 0 0 0 1rem;
margin: 1rem 0;
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;
}
.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;
text-align: center;
}
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 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>/&lt;slug&gt;/</code> zeigt einen einzelnen Post.
</header>
<nav id="breadcrumb" hidden>
<a href="/" data-link>← Zurück zur Übersicht</a>
</nav>
<div id="content">
<p class="status">Lade von Nostr-Relays …</p>
</div>
<footer>
<a href="https://forgejo.joerglohrer.synology.me/joerglohrer/joerglohrerde">Quellcode &amp; Spezifikation</a>
</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');
const $breadcrumb = document.getElementById('breadcrumb');
const pool = new SimplePool();
// Profil-Cache: einmal laden, session-weit wiederverwenden
let profilePromise = null;
function loadProfile() {
if (profilePromise) return profilePromise;
profilePromise = new Promise(resolve => {
let done = false;
const timeout = setTimeout(() => {
if (!done) { done = true; try { sub.close(); } catch {} resolve(null); }
}, TIMEOUT_MS);
const sub = pool.subscribeMany(RELAYS, [
{ kinds: [0], authors: [PUBKEY], limit: 1 }
], {
onevent(ev) {
if (done) return;
done = true;
clearTimeout(timeout);
try { sub.close(); } catch {}
try {
resolve(JSON.parse(ev.content));
} catch {
resolve(null);
}
},
oneose() {
if (done) return;
done = true;
clearTimeout(timeout);
resolve(null);
},
});
});
return profilePromise;
}
function profileCardHtml(profile) {
if (!profile) return '';
const name = profile.display_name || profile.name || '';
const avatar = profile.picture || '';
const about = profile.about || '';
const nip05 = profile.nip05 || '';
const website = profile.website || '';
const metaBits = [];
if (nip05) metaBits.push(escapeHtml(nip05));
if (website) metaBits.push(`<a href="${escapeHtml(website)}" target="_blank" rel="noopener">${escapeHtml(website.replace(/^https?:\/\//, ''))}</a>`);
const metaHtml = metaBits.length
? `<div class="meta-line">${metaBits.join('<span class="sep">·</span>')}</div>`
: '';
const avatarHtml = avatar
? `<img class="avatar" src="${escapeHtml(avatar)}" alt="${escapeHtml(name)}">`
: `<div class="avatar"></div>`;
return `
<div class="profile">
${avatarHtml}
<div class="info">
<div class="name">${escapeHtml(name)}</div>
${about ? `<div class="about">${escapeHtml(about)}</div>` : ''}
${metaHtml}
</div>
</div>
`;
}
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) {
// Dedup, falls ein Client doppelte Tags geschrieben hat
return [...new Set(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`;
$breadcrumb.hidden = false;
$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';
}
}
let cachedProfile = null;
loadProfile().then(p => {
cachedProfile = p;
// Falls Liste schon gerendert ist (ohne Profil), nachziehen
const placeholder = $content.querySelector('[data-profile-placeholder]');
if (placeholder) placeholder.outerHTML = profileCardHtml(p);
});
function renderList(events) {
const name = cachedProfile?.display_name || cachedProfile?.name || 'Jörg Lohrer';
document.title = `${name} 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('');
const profileHtml = cachedProfile
? profileCardHtml(cachedProfile)
: '<div data-profile-placeholder></div>';
$content.innerHTML = `
${profileHtml}
<h1 class="list-title">Beiträge</h1>
${itemsHtml}
`;
}
function showError(msg) {
$breadcrumb.hidden = true;
$content.innerHTML = `<p class="status error">${escapeHtml(msg)}</p>`;
}
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
};
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()]);
},
});
}
// Erkennt Legacy-Hugo-URL /YYYY/MM/DD/<dtag>.html oder .../<dtag>.html/
// Returns <dtag> oder null.
function parseLegacyUrl(path) {
const m = path.match(/^\d{4}\/\d{2}\/\d{2}\/([^/]+?)\.html\/?$/);
return m ? decodeURIComponent(m[1]) : null;
}
function route() {
// Pfad normalisieren: Slashes, "index.html"
const path = location.pathname.replace(/^\/+|\/+$/g, '');
// Leer → Liste
if (path === '' || path === 'index.html') {
loadList();
window.scrollTo(0, 0);
return;
}
// Legacy-Form YYYY/MM/DD/<dtag>.html/ → auf kurze Form umschreiben
const legacyDtag = parseLegacyUrl(path);
if (legacyDtag) {
history.replaceState(null, '', `/${encodeURIComponent(legacyDtag)}/`);
loadPost(legacyDtag);
window.scrollTo(0, 0);
return;
}
// Kanonische kurze Form /<dtag>/ → Post laden
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>