joerglohrerde/preview/spa-mini/index.html

302 lines
7.8 KiB
HTML
Raw 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,
.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 &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 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>