302 lines
7.8 KiB
HTML
302 lines
7.8 KiB
HTML
<!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,
|
||
.intro {
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 0.7rem 1rem;
|
||
margin-bottom: 1rem;
|
||
background: var(--code-bg);
|
||
font-size: 0.85rem;
|
||
color: var(--muted);
|
||
}
|
||
.intro {
|
||
margin-bottom: 2rem;
|
||
}
|
||
header.banner strong { color: var(--fg); }
|
||
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 {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border-radius: 4px;
|
||
}
|
||
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 einen einzigen, hartcodierten
|
||
Nostr-Post live von Public-Relays und rendert ihn im Browser.
|
||
</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>
|
||
|
||
<div id="content">
|
||
<p class="status">Lade Post von Nostr-Relays …</p>
|
||
</div>
|
||
|
||
<footer>
|
||
<a href="https://forgejo.joerglohrer.synology.me/joerglohrer/joerglohrerde">Quellcode & 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 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('&', '&').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) {
|
||
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>
|