Compare commits
37 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
7ea29941a6 | |
|
|
51f0ae5067 | |
|
|
9d41a68ef9 | |
|
|
32a39144bb | |
|
|
3ad1a72d84 | |
|
|
eb400a8a6a | |
|
|
22935d6737 | |
|
|
3b0f059cea | |
|
|
c089d9e429 | |
|
|
feb336fc5b | |
|
|
dcef74e75c | |
|
|
f470732c2c | |
|
|
bab2895848 | |
|
|
09f2ce8b49 | |
|
|
078423a1b2 | |
|
|
0bf9bf3bf2 | |
|
|
6f9f53c561 | |
|
|
ec9d361a13 | |
|
|
2bcb2451b4 | |
|
|
8af049a9ff | |
|
|
1fb77669e6 | |
|
|
c539c4fee3 | |
|
|
36dd76a88f | |
|
|
47decd9b70 | |
|
|
bf3d82d266 | |
|
|
b5fbfb0e85 | |
|
|
bc02a80e10 | |
|
|
5b9773ccd3 | |
|
|
64640a5eed | |
|
|
3bcc4a7170 | |
|
|
1147980f2a | |
|
|
fc6e0fecdb | |
|
|
2e18e68907 | |
|
|
865e429c5a | |
|
|
f070ea33c0 | |
|
|
1ae6445c84 | |
|
|
0679a335f4 |
|
|
@ -1 +1,4 @@
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
logs/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# npm
|
||||||
|
package-lock.json
|
||||||
|
*.log
|
||||||
|
test-results/
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
npx sv@0.15.1 create --template minimal --types ts --install npm .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"deploy:svelte": "../scripts/deploy-svelte.sh"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.57.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"svelte": "^5.55.2",
|
||||||
|
"svelte-check": "^4.4.6",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vite": "^8.0.7",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"applesauce-core": "^5.2.0",
|
||||||
|
"applesauce-loaders": "^5.1.0",
|
||||||
|
"applesauce-relay": "^5.2.0",
|
||||||
|
"applesauce-signers": "^5.2.0",
|
||||||
|
"dompurify": "^3.4.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"marked": "^18.0.0",
|
||||||
|
"nostr-tools": "^2.23.3",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'tests/e2e',
|
||||||
|
use: { baseURL: 'http://localhost:5173' },
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120_000
|
||||||
|
},
|
||||||
|
timeout: 60_000
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
||||||
|
<meta property="og:title" content="Jörg Lohrer – Blog" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://svelte.joerg-lohrer.de/" />
|
||||||
|
<meta property="og:description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
<title>Jörg Lohrer</title>
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,56 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { externalClientLinks } from '$lib/nostr/naddr';
|
||||||
|
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dtag: string;
|
||||||
|
}
|
||||||
|
let { dtag }: Props = $props();
|
||||||
|
|
||||||
|
const links = $derived(
|
||||||
|
externalClientLinks({
|
||||||
|
pubkey: AUTHOR_PUBKEY_HEX,
|
||||||
|
kind: 30023,
|
||||||
|
identifier: dtag
|
||||||
|
})
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="external">
|
||||||
|
<span class="label">In Nostr-Client öffnen (für Threads, Reactions, Teilen):</span>
|
||||||
|
<ul>
|
||||||
|
{#each links as l}
|
||||||
|
<li><a href={l.url} target="_blank" rel="noopener">{l.label}</a></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.external {
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
li a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
li a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
hablaLink?: string;
|
||||||
|
}
|
||||||
|
let { loading, error, hablaLink }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading && !error}
|
||||||
|
<p class="status">Lade von Nostr-Relays …</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="status status-error">
|
||||||
|
{error}
|
||||||
|
{#if hablaLink}
|
||||||
|
<br />
|
||||||
|
<a href={hablaLink} target="_blank" rel="noopener"> In Habla.news öffnen </a>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.status-error {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.status-error {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NostrEvent } from '$lib/nostr/loaders';
|
||||||
|
import { canonicalPostPath } from '$lib/url/legacy';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
let { event }: Props = $props();
|
||||||
|
|
||||||
|
function tagValue(e: NostrEvent, name: string): string {
|
||||||
|
return e.tags.find((t) => t[0] === name)?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dtag = $derived(tagValue(event, 'd'));
|
||||||
|
const title = $derived(tagValue(event, 'title') || '(ohne Titel)');
|
||||||
|
const summary = $derived(tagValue(event, 'summary'));
|
||||||
|
const image = $derived(tagValue(event, 'image'));
|
||||||
|
const publishedAt = $derived(
|
||||||
|
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
|
||||||
|
);
|
||||||
|
const date = $derived(
|
||||||
|
new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const href = $derived(canonicalPostPath(dtag));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a class="card" {href}>
|
||||||
|
<div
|
||||||
|
class="thumb"
|
||||||
|
style:background-image={image ? `url('${image}')` : undefined}
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
<div class="text">
|
||||||
|
<div class="meta">{date}</div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{#if summary}<p class="excerpt">{summary}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
flex: 0 0 120px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--code-bg) center/cover no-repeat;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--fg);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.excerpt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.card {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2 / 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NostrEvent } from '$lib/nostr/loaders';
|
||||||
|
import type { SignedEvent } from '$lib/nostr/signer';
|
||||||
|
import { renderMarkdown } from '$lib/render/markdown';
|
||||||
|
import Reactions from './Reactions.svelte';
|
||||||
|
import ReplyList from './ReplyList.svelte';
|
||||||
|
import ReplyComposer from './ReplyComposer.svelte';
|
||||||
|
import ExternalClientLinks from './ExternalClientLinks.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
let { event }: Props = $props();
|
||||||
|
|
||||||
|
function tagValue(e: NostrEvent, name: string): string {
|
||||||
|
return e.tags.find((t) => t[0] === name)?.[1] ?? '';
|
||||||
|
}
|
||||||
|
function tagsAll(e: NostrEvent, name: string): string[] {
|
||||||
|
return e.tags.filter((t) => t[0] === name).map((t) => t[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dtag = $derived(tagValue(event, 'd'));
|
||||||
|
const title = $derived(tagValue(event, 'title') || '(ohne Titel)');
|
||||||
|
const summary = $derived(tagValue(event, 'summary'));
|
||||||
|
const image = $derived(tagValue(event, 'image'));
|
||||||
|
const publishedAt = $derived(
|
||||||
|
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
|
||||||
|
);
|
||||||
|
const date = $derived(
|
||||||
|
new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const tags = $derived(tagsAll(event, 't'));
|
||||||
|
const bodyHtml = $derived(renderMarkdown(event.content));
|
||||||
|
|
||||||
|
// Optimistisch gesendete Replies: der Composer pusht sie rein,
|
||||||
|
// ReplyList merged sie mit den vom Relay geladenen Replies (dedup per id).
|
||||||
|
let optimisticReplies: NostrEvent[] = $state([]);
|
||||||
|
function handlePublished(signed: SignedEvent) {
|
||||||
|
optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent];
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
document.title = `${title} – Jörg Lohrer`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1 class="post-title">{title}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
Veröffentlicht am {date}
|
||||||
|
{#if tags.length > 0}
|
||||||
|
<div class="tags">
|
||||||
|
{#each tags as t}
|
||||||
|
<a class="tag" href="/tag/{encodeURIComponent(t)}/">{t}</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if image}
|
||||||
|
<p class="cover"><img src={image} alt="Cover-Bild" /></p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if summary}
|
||||||
|
<p class="summary">{summary}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<article>{@html bodyHtml}</article>
|
||||||
|
|
||||||
|
{#if dtag}
|
||||||
|
<Reactions {dtag} />
|
||||||
|
<ExternalClientLinks {dtag} />
|
||||||
|
<ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} />
|
||||||
|
<ReplyList {dtag} optimistic={optimisticReplies} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.post-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.post-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.tags {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
margin: 0 4px 4px 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tag:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.cover {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 1rem auto 1.5rem;
|
||||||
|
}
|
||||||
|
.cover img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
article :global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
article :global(a) {
|
||||||
|
color: var(--accent);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
article :global(pre) {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.88em;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
article :global(code) {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.92em;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
article :global(pre code) {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
article :global(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
article :global(blockquote) {
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
padding: 0 0 0 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Profile } from '$lib/nostr/loaders';
|
||||||
|
interface Props {
|
||||||
|
profile: Profile | null;
|
||||||
|
}
|
||||||
|
let { profile }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if profile}
|
||||||
|
<div class="profile">
|
||||||
|
{#if profile.picture}
|
||||||
|
<img class="avatar" src={profile.picture} alt={profile.display_name ?? profile.name ?? ''} />
|
||||||
|
{:else}
|
||||||
|
<div class="avatar"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">{profile.display_name ?? profile.name ?? ''}</div>
|
||||||
|
{#if profile.about}
|
||||||
|
<div class="about">{profile.about}</div>
|
||||||
|
{/if}
|
||||||
|
{#if profile.nip05 || profile.website}
|
||||||
|
<div class="meta-line">
|
||||||
|
{#if profile.nip05}<span>{profile.nip05}</span>{/if}
|
||||||
|
{#if profile.nip05 && profile.website}<span class="sep">·</span>{/if}
|
||||||
|
{#if profile.website}
|
||||||
|
<a href={profile.website} target="_blank" rel="noopener">
|
||||||
|
{profile.website.replace(/^https?:\/\//, '')}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
flex: 0 0 80px;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
}
|
||||||
|
.about {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
}
|
||||||
|
.meta-line {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.meta-line a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.meta-line a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.sep {
|
||||||
|
margin: 0 0.4rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ReactionSummary } from '$lib/nostr/loaders';
|
||||||
|
import { loadReactions } from '$lib/nostr/loaders';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dtag: string;
|
||||||
|
}
|
||||||
|
let { dtag }: Props = $props();
|
||||||
|
|
||||||
|
let reactions: ReactionSummary[] = $state([]);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
reactions = await loadReactions(dtag);
|
||||||
|
} catch {
|
||||||
|
reactions = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function displayChar(c: string): string {
|
||||||
|
if (c === '+' || c === '') return '👍';
|
||||||
|
if (c === '-') return '👎';
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if reactions.length > 0}
|
||||||
|
<div class="reactions">
|
||||||
|
{#each reactions as r}
|
||||||
|
<span class="reaction">
|
||||||
|
<span class="emoji">{displayChar(r.content)}</span>
|
||||||
|
<span class="count">{r.count}</span>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.reaction {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import {
|
||||||
|
hasNip07,
|
||||||
|
getPublicKey,
|
||||||
|
signEvent,
|
||||||
|
type SignedEvent,
|
||||||
|
type UnsignedEvent
|
||||||
|
} from '$lib/nostr/signer';
|
||||||
|
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
||||||
|
import { pool } from '$lib/nostr/pool';
|
||||||
|
import { readRelays } from '$lib/stores/readRelays';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** d-Tag des Posts, auf den geantwortet wird */
|
||||||
|
dtag: string;
|
||||||
|
/** Event-ID des ursprünglichen Posts (für e-Tag) */
|
||||||
|
eventId: string;
|
||||||
|
/** Callback, wenn ein Reply erfolgreich publiziert wurde */
|
||||||
|
onPublished?: (ev: SignedEvent) => void;
|
||||||
|
}
|
||||||
|
let { dtag, eventId, onPublished }: Props = $props();
|
||||||
|
|
||||||
|
let text = $state('');
|
||||||
|
let publishing = $state(false);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
let info: string | null = $state(null);
|
||||||
|
|
||||||
|
const nip07 = hasNip07();
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error = null;
|
||||||
|
info = null;
|
||||||
|
if (!text.trim()) {
|
||||||
|
error = 'Leeres Kommentar — nichts zu senden.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publishing = true;
|
||||||
|
try {
|
||||||
|
const pubkey = await getPublicKey();
|
||||||
|
if (!pubkey) {
|
||||||
|
error = 'Nostr-Extension (z. B. Alby) hat den Pubkey nicht geliefert.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unsigned: UnsignedEvent = {
|
||||||
|
kind: 1,
|
||||||
|
pubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['a', `30023:${AUTHOR_PUBKEY_HEX}:${dtag}`],
|
||||||
|
['e', eventId, '', 'root'],
|
||||||
|
['p', AUTHOR_PUBKEY_HEX]
|
||||||
|
],
|
||||||
|
content: text.trim()
|
||||||
|
};
|
||||||
|
const signed = await signEvent(unsigned);
|
||||||
|
if (!signed) {
|
||||||
|
error = 'Signatur wurde abgelehnt oder ist fehlgeschlagen.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const relays = get(readRelays);
|
||||||
|
const results = await pool.publish(relays, signed);
|
||||||
|
const okCount = results.filter((r) => r.ok).length;
|
||||||
|
if (okCount === 0) {
|
||||||
|
error = 'Kein Relay hat den Kommentar akzeptiert.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info = `Kommentar gesendet (${okCount}/${results.length} Relays).`;
|
||||||
|
text = '';
|
||||||
|
onPublished?.(signed);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||||
|
} finally {
|
||||||
|
publishing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
{#if !nip07}
|
||||||
|
<p class="hint">
|
||||||
|
Um zu kommentieren, benötigst du eine Nostr-Extension
|
||||||
|
(<a href="https://getalby.com" target="_blank" rel="noopener">Alby</a>,
|
||||||
|
<a href="https://github.com/fiatjaf/nos2x" target="_blank" rel="noopener">nos2x</a>), oder
|
||||||
|
kommentiere direkt in einem Nostr-Client.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<textarea
|
||||||
|
bind:value={text}
|
||||||
|
placeholder="Dein Kommentar …"
|
||||||
|
rows="4"
|
||||||
|
disabled={publishing}
|
||||||
|
></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick={submit} disabled={publishing || !text.trim()}>
|
||||||
|
{publishing ? 'Sende …' : 'Kommentar senden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if error}<p class="error">{error}</p>{/if}
|
||||||
|
{#if info}<p class="info">{info}</p>{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.composer {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
|
||||||
|
import { getProfile } from '$lib/nostr/profileCache';
|
||||||
|
import { buildNjumpProfileUrl } from '$lib/nostr/naddr';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
let { event }: Props = $props();
|
||||||
|
|
||||||
|
const date = $derived(new Date(event.created_at * 1000).toLocaleString('de-DE'));
|
||||||
|
const npubPrefix = $derived(event.pubkey.slice(0, 12) + '…');
|
||||||
|
const profileUrl = $derived(buildNjumpProfileUrl(event.pubkey));
|
||||||
|
|
||||||
|
let profile = $state<Profile | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
profile = await getProfile(event.pubkey);
|
||||||
|
} catch {
|
||||||
|
profile = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayName = $derived(profile?.display_name || profile?.name || npubPrefix);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li class="reply">
|
||||||
|
<a class="header" href={profileUrl} target="_blank" rel="noopener">
|
||||||
|
{#if profile?.picture}
|
||||||
|
<img class="avatar" src={profile.picture} alt={displayName} />
|
||||||
|
{:else}
|
||||||
|
<div class="avatar avatar-placeholder" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="meta">
|
||||||
|
<span class="name">{displayName}</span>
|
||||||
|
<span class="date">{date}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="content">{event.content}</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reply {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.8rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
margin-left: -2px;
|
||||||
|
}
|
||||||
|
.header:hover {
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.header:hover .name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.avatar-placeholder {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin-left: calc(32px + 0.6rem);
|
||||||
|
}
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { NostrEvent } from '$lib/nostr/loaders';
|
||||||
|
import { loadReplies } from '$lib/nostr/loaders';
|
||||||
|
import ReplyItem from './ReplyItem.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dtag: string;
|
||||||
|
/**
|
||||||
|
* Optimistisch hinzugefügte Events (z. B. frisch gesendete Kommentare).
|
||||||
|
* Werden vor dem Rendern zur geladenen Liste gemerged, dedupliziert per id.
|
||||||
|
*/
|
||||||
|
optimistic?: NostrEvent[];
|
||||||
|
}
|
||||||
|
let { dtag, optimistic = [] }: Props = $props();
|
||||||
|
|
||||||
|
let fetched: NostrEvent[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
const merged = $derived.by(() => {
|
||||||
|
const byId = new Map<string, NostrEvent>();
|
||||||
|
for (const ev of fetched) byId.set(ev.id, ev);
|
||||||
|
for (const ev of optimistic) byId.set(ev.id, ev);
|
||||||
|
return [...byId.values()].sort((a, b) => a.created_at - b.created_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
fetched = await loadReplies(dtag);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="replies">
|
||||||
|
<h3>Kommentare ({merged.length})</h3>
|
||||||
|
{#if loading}
|
||||||
|
<p class="hint">Lade Kommentare …</p>
|
||||||
|
{:else if merged.length === 0}
|
||||||
|
<p class="hint">Noch keine Kommentare.</p>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
{#each merged as reply (reply.id)}
|
||||||
|
<ReplyItem event={reply} />
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.replies {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Nostr-Konfiguration der SPA.
|
||||||
|
*
|
||||||
|
* Wichtig: Der AUTHOR_PUBKEY_HEX muss synchron zum tatsächlichen
|
||||||
|
* Autorenkonto sein (siehe docs/superpowers/specs/2026-04-15-nostr-page-design.md).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9 in hex */
|
||||||
|
export const AUTHOR_PUBKEY_HEX =
|
||||||
|
'4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
|
||||||
|
|
||||||
|
/** Bootstrap-Relay für das initiale Lesen von kind:10002 */
|
||||||
|
export const BOOTSTRAP_RELAY = 'wss://relay.damus.io';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback, falls kind:10002 nicht geladen werden kann.
|
||||||
|
* Bootstrap-Relay ist bewusst als erster Eintrag Teil der Liste — ein Ort der Wahrheit.
|
||||||
|
*/
|
||||||
|
export const FALLBACK_READ_RELAYS = [
|
||||||
|
BOOTSTRAP_RELAY,
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
'wss://relay.tchncs.de',
|
||||||
|
'wss://relay.edufeed.org',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Habla.news-Route für Addressable Events — URL endet auf `/a/`, der
|
||||||
|
* vollständige Deep-Link wird durch Anhängen des `naddr1…`-Bech32 gebildet.
|
||||||
|
*/
|
||||||
|
export const HABLA_BASE = 'https://habla.news/a/';
|
||||||
|
|
||||||
|
/** Soft-Timeout: einzelne Relay-Abfrage darf nicht länger als diese Dauer blockieren. */
|
||||||
|
export const RELAY_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
/** Hard-Timeout: Page-Budget, nach dem eine Route-Abfrage endgültig abbricht. */
|
||||||
|
export const RELAY_HARD_TIMEOUT_MS = 15000;
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { lastValueFrom, timeout, toArray, EMPTY, tap } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import type { NostrEvent } from 'applesauce-core/helpers/event';
|
||||||
|
import type { Filter as ApplesauceFilter } from 'applesauce-core/helpers/filter';
|
||||||
|
import { pool } from './pool';
|
||||||
|
import { readRelays } from '$lib/stores/readRelays';
|
||||||
|
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
|
||||||
|
|
||||||
|
/** Re-export als sprechenden Alias */
|
||||||
|
export type { NostrEvent };
|
||||||
|
|
||||||
|
/** Profile-Content (kind:0) */
|
||||||
|
export interface Profile {
|
||||||
|
name?: string;
|
||||||
|
display_name?: string;
|
||||||
|
picture?: string;
|
||||||
|
banner?: string;
|
||||||
|
about?: string;
|
||||||
|
website?: string;
|
||||||
|
nip05?: string;
|
||||||
|
lud16?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Filter = ApplesauceFilter;
|
||||||
|
|
||||||
|
interface CollectOpts {
|
||||||
|
onEvent?: (ev: NostrEvent) => void;
|
||||||
|
hardTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet eine Request-Subscription und sammelt alle gelieferten Events
|
||||||
|
* bis EOSE (pool.request completes nach EOSE) oder Hard-Timeout.
|
||||||
|
*/
|
||||||
|
async function collectEvents(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
opts: CollectOpts = {}
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
const events = await lastValueFrom(
|
||||||
|
pool.request(relays, filter).pipe(
|
||||||
|
tap((ev: NostrEvent) => opts.onEvent?.(ev)),
|
||||||
|
timeout(opts.hardTimeoutMs ?? RELAY_HARD_TIMEOUT_MS),
|
||||||
|
toArray(),
|
||||||
|
catchError(() => EMPTY)
|
||||||
|
),
|
||||||
|
{ defaultValue: [] as NostrEvent[] }
|
||||||
|
);
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dedup per d-Tag: neueste (created_at) wins */
|
||||||
|
function dedupByDtag(events: NostrEvent[]): NostrEvent[] {
|
||||||
|
const byDtag = new Map<string, NostrEvent>();
|
||||||
|
for (const ev of events) {
|
||||||
|
const d = ev.tags.find((t) => t[0] === 'd')?.[1];
|
||||||
|
if (!d) continue;
|
||||||
|
const existing = byDtag.get(d);
|
||||||
|
if (!existing || ev.created_at > existing.created_at) {
|
||||||
|
byDtag.set(d, ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byDtag.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle kind:30023-Posts des Autors, neueste zuerst */
|
||||||
|
export async function loadPostList(
|
||||||
|
onEvent?: (ev: NostrEvent) => void
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
const relays = get(readRelays);
|
||||||
|
const events = await collectEvents(
|
||||||
|
relays,
|
||||||
|
{ kinds: [30023], authors: [AUTHOR_PUBKEY_HEX], limit: 200 },
|
||||||
|
{ onEvent }
|
||||||
|
);
|
||||||
|
const deduped = dedupByDtag(events);
|
||||||
|
return deduped.sort((a, b) => {
|
||||||
|
const ap = parseInt(
|
||||||
|
a.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${a.created_at}`,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const bp = parseInt(
|
||||||
|
b.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${b.created_at}`,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
return bp - ap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einzelpost per d-Tag */
|
||||||
|
export async function loadPost(dtag: string): Promise<NostrEvent | null> {
|
||||||
|
const relays = get(readRelays);
|
||||||
|
const events = await collectEvents(relays, {
|
||||||
|
kinds: [30023],
|
||||||
|
authors: [AUTHOR_PUBKEY_HEX],
|
||||||
|
'#d': [dtag],
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
return events.reduce((best, cur) =>
|
||||||
|
cur.created_at > best.created_at ? cur : best
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profil-Event kind:0 (neueste Version).
|
||||||
|
* Default: Autoren-Pubkey der SPA. Optional: beliebiger Pubkey für
|
||||||
|
* die Anzeige fremder Kommentar-Autoren.
|
||||||
|
*/
|
||||||
|
export async function loadProfile(pubkey: string = AUTHOR_PUBKEY_HEX): Promise<Profile | null> {
|
||||||
|
const relays = get(readRelays);
|
||||||
|
const events = await collectEvents(relays, {
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
const latest = events.reduce((best, cur) =>
|
||||||
|
cur.created_at > best.created_at ? cur : best
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return JSON.parse(latest.content) as Profile;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post-Adresse im `a`-Tag-Format: "30023:<pubkey>:<dtag>" */
|
||||||
|
function eventAddress(pubkey: string, dtag: string): string {
|
||||||
|
return `30023:${pubkey}:${dtag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle kind:1-Replies auf einen Post, chronologisch aufsteigend (älteste zuerst).
|
||||||
|
* Streamt via onEvent, wenn angegeben.
|
||||||
|
*/
|
||||||
|
export async function loadReplies(
|
||||||
|
dtag: string,
|
||||||
|
onEvent?: (ev: NostrEvent) => void
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
const relays = get(readRelays);
|
||||||
|
const address = eventAddress(AUTHOR_PUBKEY_HEX, dtag);
|
||||||
|
const events = await collectEvents(
|
||||||
|
relays,
|
||||||
|
{ kinds: [1], '#a': [address], limit: 500 },
|
||||||
|
{ onEvent }
|
||||||
|
);
|
||||||
|
return events.sort((a, b) => a.created_at - b.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtert Post-Liste clientseitig nach Tag-Name.
|
||||||
|
* (Relay-seitige #t-Filter werden nicht von allen Relays unterstützt — safer
|
||||||
|
* ist es, die ganze Liste zu laden und lokal zu filtern.)
|
||||||
|
*/
|
||||||
|
export async function loadPostsByTag(tagName: string): Promise<NostrEvent[]> {
|
||||||
|
const all = await loadPostList();
|
||||||
|
const norm = tagName.toLowerCase();
|
||||||
|
return all.filter((ev) =>
|
||||||
|
ev.tags.some((t) => t[0] === 't' && t[1]?.toLowerCase() === norm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactionSummary {
|
||||||
|
/** Emoji oder "+"/"-" */
|
||||||
|
content: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregiert kind:7-Reactions auf einen Post.
|
||||||
|
* Gruppiert nach content, zählt Anzahl.
|
||||||
|
*/
|
||||||
|
export async function loadReactions(dtag: string): Promise<ReactionSummary[]> {
|
||||||
|
const relays = get(readRelays);
|
||||||
|
const address = eventAddress(AUTHOR_PUBKEY_HEX, dtag);
|
||||||
|
const events = await collectEvents(relays, {
|
||||||
|
kinds: [7],
|
||||||
|
'#a': [address],
|
||||||
|
limit: 500
|
||||||
|
});
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const ev of events) {
|
||||||
|
const key = ev.content || '+';
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([content, count]) => ({ content, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { HABLA_BASE } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Argumente für NIP-19 addressable-event-Pointer.
|
||||||
|
* Validierung (hex-Länge etc.) wird an `nip19.naddrEncode` delegiert.
|
||||||
|
*/
|
||||||
|
export interface NaddrArgs {
|
||||||
|
pubkey: string;
|
||||||
|
kind: number;
|
||||||
|
identifier: string;
|
||||||
|
relays?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut einen `naddr1…`-Bech32-String (NIP-19) für ein addressable Event.
|
||||||
|
* Wird u. a. für Habla.news-Deep-Links genutzt.
|
||||||
|
*/
|
||||||
|
export function buildNaddr(args: NaddrArgs): string {
|
||||||
|
return nip19.naddrEncode({
|
||||||
|
pubkey: args.pubkey,
|
||||||
|
kind: args.kind,
|
||||||
|
identifier: args.identifier,
|
||||||
|
relays: args.relays ?? []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Habla.news-Deep-Link auf ein addressable Event.
|
||||||
|
* Fallback für „Post nicht gefunden" / JS-lose Clients.
|
||||||
|
*/
|
||||||
|
export function buildHablaLink(args: NaddrArgs): string {
|
||||||
|
return `${HABLA_BASE}${buildNaddr(args)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `npub1…`-Bech32-String für einen Pubkey — für Profil-Links außerhalb
|
||||||
|
* der SPA (z. B. njump.me).
|
||||||
|
*/
|
||||||
|
export function buildNpub(pubkeyHex: string): string {
|
||||||
|
return nip19.npubEncode(pubkeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* njump.me-Profil-URL. Öffnet das Nostr-native Profil-Browser mit
|
||||||
|
* vollständiger Event-Historie.
|
||||||
|
*/
|
||||||
|
export function buildNjumpProfileUrl(pubkeyHex: string): string {
|
||||||
|
return `https://njump.me/${buildNpub(pubkeyHex)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste externer Nostr-Clients für „Post öffnen in …"-Links.
|
||||||
|
* Nutzt naddr, damit jeder Client das addressable Event adressieren kann.
|
||||||
|
* EduFeed zuerst — OER/OEP-Bildungscommunity, wichtig für Jörgs Zielgruppe.
|
||||||
|
*/
|
||||||
|
export function externalClientLinks(
|
||||||
|
args: NaddrArgs
|
||||||
|
): { label: string; url: string }[] {
|
||||||
|
const naddr = buildNaddr(args);
|
||||||
|
return [
|
||||||
|
{ label: 'EduFeed', url: `https://edufeed.org/${naddr}` },
|
||||||
|
{ label: 'Habla', url: `https://habla.news/a/${naddr}` },
|
||||||
|
{ label: 'Yakihonne', url: `https://yakihonne.com/article/${naddr}` }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { RelayPool } from 'applesauce-relay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton-Pool für alle Nostr-Requests der SPA.
|
||||||
|
* applesauce-relay verwaltet Reconnects, Subscriptions, deduping intern.
|
||||||
|
*/
|
||||||
|
export const pool = new RelayPool();
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Profile } from './loaders';
|
||||||
|
import { loadProfile } from './loaders';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sessionsweiter Cache für kind:0-Profile.
|
||||||
|
* Jeder Pubkey wird maximal einmal angefragt; mehrfache parallele
|
||||||
|
* Aufrufe teilen sich dieselbe Promise.
|
||||||
|
*/
|
||||||
|
const cache = new Map<string, Promise<Profile | null>>();
|
||||||
|
|
||||||
|
export function getProfile(pubkey: string): Promise<Profile | null> {
|
||||||
|
const existing = cache.get(pubkey);
|
||||||
|
if (existing) return existing;
|
||||||
|
const pending = loadProfile(pubkey);
|
||||||
|
cache.set(pubkey, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { lastValueFrom, timeout, toArray, EMPTY } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import type { NostrEvent } from 'applesauce-core/helpers/event';
|
||||||
|
import { pool } from './pool';
|
||||||
|
import {
|
||||||
|
AUTHOR_PUBKEY_HEX,
|
||||||
|
BOOTSTRAP_RELAY,
|
||||||
|
FALLBACK_READ_RELAYS,
|
||||||
|
RELAY_TIMEOUT_MS
|
||||||
|
} from './config';
|
||||||
|
|
||||||
|
export interface OutboxRelay {
|
||||||
|
url: string;
|
||||||
|
/** true = zum Lesen zu nutzen (kein dritter Tag-Wert oder "read") */
|
||||||
|
read: boolean;
|
||||||
|
/** true = zum Schreiben zu nutzen (kein dritter Tag-Wert oder "write") */
|
||||||
|
write: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die NIP-65-Relay-Liste (kind:10002) des Autors vom Bootstrap-Relay.
|
||||||
|
* Fallback auf FALLBACK_READ_RELAYS, wenn das Event nicht innerhalb von
|
||||||
|
* RELAY_TIMEOUT_MS gefunden wird.
|
||||||
|
*
|
||||||
|
* Interpretation des dritten Tag-Werts:
|
||||||
|
* - nicht gesetzt → read + write
|
||||||
|
* - "read" → nur read
|
||||||
|
* - "write" → nur write
|
||||||
|
*/
|
||||||
|
export async function loadOutboxRelays(): Promise<OutboxRelay[]> {
|
||||||
|
const event = await firstEvent();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const relays: OutboxRelay[] = [];
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] !== 'r' || !tag[1]) continue;
|
||||||
|
const mode = tag[2];
|
||||||
|
relays.push({
|
||||||
|
url: tag[1],
|
||||||
|
read: mode !== 'write',
|
||||||
|
write: mode !== 'read'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relays.length === 0) {
|
||||||
|
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return relays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur die Read-URLs aus OutboxRelay[] */
|
||||||
|
export function readUrls(relays: OutboxRelay[]): string[] {
|
||||||
|
return relays.filter((r) => r.read).map((r) => r.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur die Write-URLs aus OutboxRelay[] */
|
||||||
|
export function writeUrls(relays: OutboxRelay[]): string[] {
|
||||||
|
return relays.filter((r) => r.write).map((r) => r.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Internes --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt das neueste kind:10002-Event vom Bootstrap-Relay ab.
|
||||||
|
* Sammelt alle Events bis EOSE (`pool.request(...)` emittiert nur Events
|
||||||
|
* und completes bei EOSE), nimmt das neueste, oder null falls keines.
|
||||||
|
*/
|
||||||
|
async function firstEvent(): Promise<NostrEvent | null> {
|
||||||
|
try {
|
||||||
|
const events = await lastValueFrom(
|
||||||
|
pool
|
||||||
|
.request([BOOTSTRAP_RELAY], {
|
||||||
|
kinds: [10002],
|
||||||
|
authors: [AUTHOR_PUBKEY_HEX],
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
timeout(RELAY_TIMEOUT_MS),
|
||||||
|
toArray(),
|
||||||
|
catchError(() => EMPTY)
|
||||||
|
),
|
||||||
|
{ defaultValue: [] as NostrEvent[] }
|
||||||
|
);
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
return events.reduce((best, cur) =>
|
||||||
|
cur.created_at > best.created_at ? cur : best
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* NIP-07-Wrapper für Browser-Extension-Signer (Alby, nos2x, Flamingo).
|
||||||
|
*
|
||||||
|
* `window.nostr` ist optional — wenn die Extension fehlt, liefern die Helper
|
||||||
|
* null zurück und der Aufrufer zeigt einen Hinweis an.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
nostr?: {
|
||||||
|
getPublicKey(): Promise<string>;
|
||||||
|
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnsignedEvent {
|
||||||
|
kind: number;
|
||||||
|
tags: string[][];
|
||||||
|
content: string;
|
||||||
|
created_at: number;
|
||||||
|
pubkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedEvent extends UnsignedEvent {
|
||||||
|
id: string;
|
||||||
|
sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasNip07(): boolean {
|
||||||
|
return typeof window !== 'undefined' && !!window.nostr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicKey(): Promise<string | null> {
|
||||||
|
if (!hasNip07()) return null;
|
||||||
|
try {
|
||||||
|
return await window.nostr!.getPublicKey();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signEvent(event: UnsignedEvent): Promise<SignedEvent | null> {
|
||||||
|
if (!hasNip07()) return null;
|
||||||
|
try {
|
||||||
|
return await window.nostr!.signEvent(event);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import javascript from 'highlight.js/lib/languages/javascript';
|
||||||
|
import bash from 'highlight.js/lib/languages/bash';
|
||||||
|
import typescript from 'highlight.js/lib/languages/typescript';
|
||||||
|
import json from 'highlight.js/lib/languages/json';
|
||||||
|
|
||||||
|
hljs.registerLanguage('javascript', javascript);
|
||||||
|
hljs.registerLanguage('js', javascript);
|
||||||
|
hljs.registerLanguage('typescript', typescript);
|
||||||
|
hljs.registerLanguage('ts', typescript);
|
||||||
|
hljs.registerLanguage('bash', bash);
|
||||||
|
hljs.registerLanguage('sh', bash);
|
||||||
|
hljs.registerLanguage('json', json);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lokaler Marked-Instance, damit die globale `marked`-Singleton nicht
|
||||||
|
* mutiert wird — andere Module können `marked` unbeeinflusst weiterverwenden.
|
||||||
|
* (Spec §3: lokale Ersetzbarkeit der Engine.)
|
||||||
|
*/
|
||||||
|
const markedInstance = new Marked({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
renderer: {
|
||||||
|
code({ text, lang }) {
|
||||||
|
const language = lang && hljs.getLanguage(lang) ? lang : undefined;
|
||||||
|
const highlighted = language
|
||||||
|
? hljs.highlight(text, { language }).value
|
||||||
|
: hljs.highlightAuto(text).value;
|
||||||
|
const cls = language ? ` language-${language}` : '';
|
||||||
|
return `<pre><code class="hljs${cls}">${highlighted}</code></pre>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert einen Markdown-String zu sanitized HTML.
|
||||||
|
* Einziger Export des Moduls — so bleibt Austausch der Engine lokal.
|
||||||
|
*
|
||||||
|
* Nur im Browser/jsdom aufrufen: DOMPurify braucht ein DOM. Die SPA
|
||||||
|
* hat SSR global ausgeschaltet (`+layout.ts: ssr = false`), Vitest läuft
|
||||||
|
* in jsdom — beide Szenarien sind abgedeckt. Ein Aufruf in reiner
|
||||||
|
* Node-Umgebung würde hier laut fehlschlagen statt stumm unsicher
|
||||||
|
* durchzulaufen.
|
||||||
|
*/
|
||||||
|
export function renderMarkdown(md: string): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('renderMarkdown: DOM-Kontext erforderlich (Browser oder jsdom).');
|
||||||
|
}
|
||||||
|
const raw = markedInstance.parse(md, { async: false }) as string;
|
||||||
|
return DOMPurify.sanitize(raw);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { writable, type Readable } from 'svelte/store';
|
||||||
|
import { loadOutboxRelays, readUrls } from '$lib/nostr/relays';
|
||||||
|
import { FALLBACK_READ_RELAYS } from '$lib/nostr/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store mit der aktuellen Read-Relay-Liste.
|
||||||
|
* Initial = FALLBACK_READ_RELAYS, damit die SPA sofort abfragen kann;
|
||||||
|
* sobald loadOutboxRelays() fertig ist, wird der Store aktualisiert.
|
||||||
|
*
|
||||||
|
* Singleton-Initialisierung: bootstrapReadRelays() wird genau einmal beim ersten
|
||||||
|
* Import aufgerufen.
|
||||||
|
*/
|
||||||
|
const store = writable<string[]>([...FALLBACK_READ_RELAYS]);
|
||||||
|
let bootstrapped = false;
|
||||||
|
|
||||||
|
export function bootstrapReadRelays(): void {
|
||||||
|
if (bootstrapped) return;
|
||||||
|
bootstrapped = true;
|
||||||
|
loadOutboxRelays()
|
||||||
|
.then((relays) => {
|
||||||
|
const urls = readUrls(relays);
|
||||||
|
if (urls.length > 0) store.set(urls);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Store behält seinen initialen FALLBACK-Zustand
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readRelays: Readable<string[]> = store;
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Erkennt Legacy-Hugo-URLs der Form /YYYY/MM/DD/<dtag>.html oder .../<dtag>.html/
|
||||||
|
* und gibt den dtag-Teil zurück. Für alle anderen Pfade: null.
|
||||||
|
*
|
||||||
|
* Erwartet nur den Pfad ohne Query/Fragment — wenn vorhanden vom Aufrufer
|
||||||
|
* trennen. `decodeURIComponent` wird defensiv gekapselt, damit malformed
|
||||||
|
* Percent-Encoding die SPA beim Boot nicht crasht.
|
||||||
|
*/
|
||||||
|
export function parseLegacyUrl(path: string): string | null {
|
||||||
|
const match = path.match(/^\/\d{4}\/\d{2}\/\d{2}\/([^/]+?)\.html\/?$/);
|
||||||
|
if (!match) return null;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(match[1]);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt die kanonische kurze Post-URL /<dtag>/.
|
||||||
|
*/
|
||||||
|
export function canonicalPostPath(dtag: string): string {
|
||||||
|
return `/${encodeURIComponent(dtag)}/`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import { bootstrapReadRelays } from '$lib/stores/readRelays';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
bootstrapReadRelays();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
main {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const prerender = false;
|
||||||
|
export const ssr = false;
|
||||||
|
export const trailingSlash = 'always';
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
|
||||||
|
import { loadPostList } from '$lib/nostr/loaders';
|
||||||
|
import { getProfile } from '$lib/nostr/profileCache';
|
||||||
|
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
||||||
|
import ProfileCard from '$lib/components/ProfileCard.svelte';
|
||||||
|
import PostCard from '$lib/components/PostCard.svelte';
|
||||||
|
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
||||||
|
|
||||||
|
let profile: Profile | null = $state(null);
|
||||||
|
let posts: NostrEvent[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [p, list] = await Promise.all([getProfile(AUTHOR_PUBKEY_HEX), loadPostList()]);
|
||||||
|
profile = p;
|
||||||
|
posts = list;
|
||||||
|
loading = false;
|
||||||
|
if (list.length === 0) {
|
||||||
|
error = 'Keine Posts gefunden auf den abgefragten Relays.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loading = false;
|
||||||
|
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const name = profile?.display_name ?? profile?.name ?? 'Jörg Lohrer';
|
||||||
|
document.title = `${name} – Blog`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProfileCard {profile} />
|
||||||
|
|
||||||
|
<h1 class="list-title">Beiträge</h1>
|
||||||
|
|
||||||
|
<LoadingOrError {loading} {error} />
|
||||||
|
|
||||||
|
{#each posts as post (post.id)}
|
||||||
|
<PostCard event={post} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.list-title {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { NostrEvent } from '$lib/nostr/loaders';
|
||||||
|
import { loadPost } from '$lib/nostr/loaders';
|
||||||
|
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
||||||
|
import { buildHablaLink } from '$lib/nostr/naddr';
|
||||||
|
import PostView from '$lib/components/PostView.svelte';
|
||||||
|
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const dtag = $derived(data.dtag);
|
||||||
|
|
||||||
|
let post: NostrEvent | null = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
|
const hablaLink = $derived(
|
||||||
|
buildHablaLink({
|
||||||
|
pubkey: AUTHOR_PUBKEY_HEX,
|
||||||
|
kind: 30023,
|
||||||
|
identifier: dtag
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const p = await loadPost(dtag);
|
||||||
|
loading = false;
|
||||||
|
if (!p) {
|
||||||
|
error = `Post "${dtag}" nicht gefunden.`;
|
||||||
|
} else {
|
||||||
|
post = p;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loading = false;
|
||||||
|
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
|
||||||
|
|
||||||
|
<LoadingOrError {loading} {error} {hablaLink} />
|
||||||
|
|
||||||
|
{#if post}
|
||||||
|
<PostView event={post} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.breadcrumb a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ url }) => {
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
// Legacy-Form /YYYY/MM/DD/<dtag>.html/ → Redirect auf /<dtag>/
|
||||||
|
const legacyDtag = parseLegacyUrl(pathname);
|
||||||
|
if (legacyDtag) {
|
||||||
|
throw redirect(301, canonicalPostPath(legacyDtag));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kanonisch: /<dtag>/ — erster Segment des Pfades.
|
||||||
|
const segments = pathname.replace(/^\/+|\/+$/g, '').split('/');
|
||||||
|
if (segments.length !== 1 || !segments[0]) {
|
||||||
|
throw error(404, 'Seite nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dtag: decodeURIComponent(segments[0]) };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { NostrEvent } from '$lib/nostr/loaders';
|
||||||
|
import { loadPostsByTag } from '$lib/nostr/loaders';
|
||||||
|
import PostCard from '$lib/components/PostCard.svelte';
|
||||||
|
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const tagName = $derived(data.tagName);
|
||||||
|
|
||||||
|
let posts: NostrEvent[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
posts = await loadPostsByTag(tagName);
|
||||||
|
loading = false;
|
||||||
|
if (posts.length === 0) {
|
||||||
|
error = `Keine Posts mit Tag "${tagName}" gefunden.`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loading = false;
|
||||||
|
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
document.title = `#${tagName} – Jörg Lohrer`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
|
||||||
|
|
||||||
|
<h1 class="tag-title">#{tagName}</h1>
|
||||||
|
|
||||||
|
<LoadingOrError {loading} {error} />
|
||||||
|
|
||||||
|
{#each posts as post (post.id)}
|
||||||
|
<PostCard event={post} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.breadcrumb a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.tag-title {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params }) => {
|
||||||
|
return { tagName: decodeURIComponent(params.name) };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# HTTPS forcieren
|
||||||
|
RewriteCond %{HTTPS} !=on
|
||||||
|
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
|
# Existierende Datei oder Verzeichnis? Direkt ausliefern.
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# Alles andere → SPA-Fallback (SvelteKit mit adapter-static)
|
||||||
|
RewriteRule ^ /index.html [L]
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
compilerOptions: {
|
||||||
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
|
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||||
|
},
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html',
|
||||||
|
precompress: false,
|
||||||
|
strict: false
|
||||||
|
}),
|
||||||
|
alias: {
|
||||||
|
$lib: 'src/lib'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('Home zeigt Profil und mindestens einen Post', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: /Beiträge/ })).toBeVisible();
|
||||||
|
await expect(page.locator('.profile .name')).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator('a.card').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('Einzelpost rendert Titel und Markdown-Body', async ({ page }) => {
|
||||||
|
await page.goto('/dezentrale-oep-oer/');
|
||||||
|
// Titel steht einmal als .post-title (H1 außerhalb des Artikels),
|
||||||
|
// und nochmal im Markdown-Body des Events — wir prüfen den ersten.
|
||||||
|
await expect(page.locator('h1.post-title')).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.locator('h1.post-title')).toContainText('Gemeinsam die Bildungszukunft');
|
||||||
|
await expect(page.locator('article')).toContainText('Open Educational');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Legacy-URL wird auf kurze Form umgeleitet', async ({ page }) => {
|
||||||
|
await page.goto('/2025/03/04/dezentrale-oep-oer.html/');
|
||||||
|
await expect(page).toHaveURL(/\/dezentrale-oep-oer\/$/);
|
||||||
|
await expect(page.locator('h1.post-title')).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy';
|
||||||
|
|
||||||
|
describe('parseLegacyUrl', () => {
|
||||||
|
it('extrahiert dtag aus der Hugo-URL-Form mit Trailing-Slash', () => {
|
||||||
|
expect(parseLegacyUrl('/2025/03/04/dezentrale-oep-oer.html/')).toBe(
|
||||||
|
'dezentrale-oep-oer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extrahiert dtag aus der Hugo-URL-Form ohne Trailing-Slash', () => {
|
||||||
|
expect(parseLegacyUrl('/2024/01/26/offenheit-das-wesentliche.html')).toBe(
|
||||||
|
'offenheit-das-wesentliche',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returned null für die kanonische kurze Form', () => {
|
||||||
|
expect(parseLegacyUrl('/dezentrale-oep-oer/')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returned null für leeren Pfad', () => {
|
||||||
|
expect(parseLegacyUrl('/')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returned null für andere Strukturen', () => {
|
||||||
|
expect(parseLegacyUrl('/tag/OER/')).toBeNull();
|
||||||
|
expect(parseLegacyUrl('/some/random/path/')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodiert percent-encoded dtags', () => {
|
||||||
|
expect(parseLegacyUrl('/2024/05/12/mit%20leerzeichen.html/')).toBe(
|
||||||
|
'mit leerzeichen',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt null zurück bei malformed percent-encoding (crash-sicher)', () => {
|
||||||
|
expect(parseLegacyUrl('/2024/01/26/%E0.html/')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt null zurück für leeren dtag', () => {
|
||||||
|
expect(parseLegacyUrl('/2024/01/26/.html/')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canonicalPostPath', () => {
|
||||||
|
it('erzeugt /<dtag>/ mit encodeURIComponent', () => {
|
||||||
|
expect(canonicalPostPath('dezentrale-oep-oer')).toBe('/dezentrale-oep-oer/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kodiert Sonderzeichen', () => {
|
||||||
|
expect(canonicalPostPath('mit leerzeichen')).toBe('/mit%20leerzeichen/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round-trip parseLegacyUrl → canonicalPostPath', () => {
|
||||||
|
it('Legacy-URL wird zur kanonischen kurzen Form', () => {
|
||||||
|
const dtag = parseLegacyUrl('/2025/03/04/dezentrale-oep-oer.html/');
|
||||||
|
expect(dtag).not.toBeNull();
|
||||||
|
expect(canonicalPostPath(dtag!)).toBe('/dezentrale-oep-oer/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { renderMarkdown } from '$lib/render/markdown';
|
||||||
|
|
||||||
|
describe('renderMarkdown', () => {
|
||||||
|
it('rendert einfachen Markdown-Text zu HTML', () => {
|
||||||
|
const html = renderMarkdown('**bold** and *italic*');
|
||||||
|
expect(html).toContain('<strong>bold</strong>');
|
||||||
|
expect(html).toContain('<em>italic</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt <script>-Tags (DOMPurify)', () => {
|
||||||
|
const html = renderMarkdown('hello <script>alert("x")</script> world');
|
||||||
|
expect(html).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt javascript:-URLs', () => {
|
||||||
|
const html = renderMarkdown('[click](javascript:alert(1))');
|
||||||
|
expect(html).not.toMatch(/javascript:/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rendert Links mit http:// und erhält das href', () => {
|
||||||
|
const html = renderMarkdown('[nostr](https://nostr.com)');
|
||||||
|
expect(html).toContain('href="https://nostr.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rendert horizontale Linie aus ---', () => {
|
||||||
|
const html = renderMarkdown('oben\n\n---\n\nunten');
|
||||||
|
expect(html).toContain('<hr>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rendert fenced code blocks mit hljs-klasse', () => {
|
||||||
|
const html = renderMarkdown('```js\nconst x = 1;\n```');
|
||||||
|
expect(html).toContain('<pre>');
|
||||||
|
expect(html).toContain('<code');
|
||||||
|
expect(html).toContain('class="hljs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rendert GFM tables', () => {
|
||||||
|
const md = '| a | b |\n|---|---|\n| 1 | 2 |';
|
||||||
|
const html = renderMarkdown(md);
|
||||||
|
expect(html).toContain('<table');
|
||||||
|
expect(html).toContain('<td>1</td>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rendert Bilder', () => {
|
||||||
|
const html = renderMarkdown('');
|
||||||
|
expect(html).toContain('<img');
|
||||||
|
expect(html).toContain('src="https://example.com/img.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Erweiterte XSS-Matrix — relevant ab Reply-Komponenten (3rd-party Content).
|
||||||
|
it('entfernt onerror-Attribute auf inline-HTML-img', () => {
|
||||||
|
const html = renderMarkdown('<img src="x" onerror="alert(1)">');
|
||||||
|
expect(html.toLowerCase()).not.toContain('onerror');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt onclick-Attribute auf inline-HTML', () => {
|
||||||
|
const html = renderMarkdown('<a href="#" onclick="alert(1)">x</a>');
|
||||||
|
expect(html.toLowerCase()).not.toContain('onclick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt iframe-Tags', () => {
|
||||||
|
const html = renderMarkdown('<iframe src="https://evil.com"></iframe>');
|
||||||
|
expect(html.toLowerCase()).not.toContain('<iframe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt data:text/html-URLs in Links', () => {
|
||||||
|
const html = renderMarkdown('[x](data:text/html,<script>alert(1)</script>)');
|
||||||
|
expect(html.toLowerCase()).not.toMatch(/href="data:text\/html/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt vbscript:-URLs', () => {
|
||||||
|
const html = renderMarkdown('<a href="vbscript:msgbox(1)">x</a>');
|
||||||
|
expect(html.toLowerCase()).not.toContain('vbscript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt script-Tag innerhalb svg', () => {
|
||||||
|
const html = renderMarkdown('<svg><script>alert(1)</script></svg>');
|
||||||
|
expect(html.toLowerCase()).not.toContain('<script');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildHablaLink } from '$lib/nostr/naddr';
|
||||||
|
|
||||||
|
describe('buildHablaLink', () => {
|
||||||
|
it('erzeugt einen habla.news/a/-Link mit naddr1-Bech32', () => {
|
||||||
|
const link = buildHablaLink({
|
||||||
|
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
|
||||||
|
kind: 30023,
|
||||||
|
identifier: 'dezentrale-oep-oer',
|
||||||
|
relays: ['wss://relay.damus.io'],
|
||||||
|
});
|
||||||
|
expect(link).toMatch(/^https:\/\/habla\.news\/a\/naddr1[a-z0-9]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist deterministisch für gleiche Inputs', () => {
|
||||||
|
const args = {
|
||||||
|
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
|
||||||
|
kind: 30023,
|
||||||
|
identifier: 'foo',
|
||||||
|
relays: ['wss://relay.damus.io'],
|
||||||
|
};
|
||||||
|
expect(buildHablaLink(args)).toBe(buildHablaLink(args));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('funktioniert ohne relays (optional)', () => {
|
||||||
|
const link = buildHablaLink({
|
||||||
|
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
|
||||||
|
kind: 30023,
|
||||||
|
identifier: 'foo',
|
||||||
|
});
|
||||||
|
expect(link).toMatch(/^https:\/\/habla\.news\/a\/naddr1[a-z0-9]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erzeugt unterschiedliche Links für unterschiedliche Inputs', () => {
|
||||||
|
const base = {
|
||||||
|
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
|
||||||
|
kind: 30023,
|
||||||
|
relays: [],
|
||||||
|
};
|
||||||
|
const a = buildHablaLink({ ...base, identifier: 'foo' });
|
||||||
|
const b = buildHablaLink({ ...base, identifier: 'bar' });
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
test: {
|
||||||
|
include: ['tests/unit/**/*.{test,spec}.{js,ts}'],
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# HTTPS forcieren (relevant sobald Zertifikat aktiv)
|
||||||
|
RewriteCond %{HTTPS} !=on
|
||||||
|
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
|
# Existierende Datei oder Verzeichnis? Direkt ausliefern.
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# Alles andere → SPA-Fallback (für die Mini-Seite optional, aber harmlos).
|
||||||
|
RewriteRule ^ /index.html [L]
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
# SPA Mini-Preview
|
||||||
|
|
||||||
|
**Tech-Spike, kein Produkt.**
|
||||||
|
|
||||||
|
Eine einzige `index.html`, die im Browser einen einzelnen Nostr-Post (`kind:30023`)
|
||||||
|
live von Public-Relays lädt und rendert. Beweist, dass die SPA-Architektur
|
||||||
|
aus [`docs/superpowers/specs/2026-04-15-nostr-page-design.md`](../../docs/superpowers/specs/2026-04-15-nostr-page-design.md)
|
||||||
|
in der Praxis funktioniert — ohne SvelteKit-Build, ohne Routing, ohne Backend.
|
||||||
|
|
||||||
|
## Was sie macht
|
||||||
|
|
||||||
|
- Lädt `nostr-tools`, `marked` und `DOMPurify` zur Laufzeit von esm.sh.
|
||||||
|
- Verbindet sich zu fünf Public-Relays.
|
||||||
|
- Holt das `kind:30023`-Event mit `d`-Tag `dezentrale-oep-oer` für den hartcodierten Pubkey.
|
||||||
|
- Rendert Markdown via `marked`, sanitized via `DOMPurify`.
|
||||||
|
- Cover-Bild wird vom Blossom-Server geladen (URL aus dem Event-Tag `image`).
|
||||||
|
|
||||||
|
## Was sie nicht macht
|
||||||
|
|
||||||
|
- Kein Routing, keine Post-Liste, keine Tags-Navigation, keine Reactions, keine Kommentare.
|
||||||
|
- Kein NIP-65-Outbox-Resolution (Relays sind hartcodiert).
|
||||||
|
- Kein NIP-07-Login.
|
||||||
|
- Kein Code-Splitting, keine Service-Worker, keine Optimierung.
|
||||||
|
|
||||||
|
Für all das wartet die echte SvelteKit-SPA — das hier ist nur das „Hello World".
|
||||||
|
|
||||||
|
## Lokal ausprobieren
|
||||||
|
|
||||||
|
Die Datei kann nicht per `file://` geöffnet werden (CORS für CDN-Imports).
|
||||||
|
Stattdessen ein lokaler HTTP-Server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd preview/spa-mini
|
||||||
|
python3 -m http.server 8000
|
||||||
|
# Browser: http://localhost:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder mit Deno:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deno run --allow-net --allow-read jsr:@std/http/file-server preview/spa-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auf die Subdomain `spa.joerg-lohrer.de` deployen
|
||||||
|
|
||||||
|
Voraussetzung: Subdomain im All-Inkl-KAS angelegt, eigener DocumentRoot eingerichtet,
|
||||||
|
SSL-Zertifikat aktiviert.
|
||||||
|
|
||||||
|
Inhalt von `preview/spa-mini/` (also `index.html` und `.htaccess`) per FTP
|
||||||
|
in den DocumentRoot der Subdomain hochladen.
|
||||||
|
|
||||||
|
Erwartetes Ergebnis: `https://spa.joerg-lohrer.de/` zeigt den Post.
|
||||||
|
|
||||||
|
## Spätere Ablösung
|
||||||
|
|
||||||
|
Sobald die SvelteKit-SPA fertig ist, wird ihr `build/`-Output denselben Webroot
|
||||||
|
ablösen. Diese Mini-Seite kann dann gelöscht oder als historisches Artefakt
|
||||||
|
im Repo bleiben.
|
||||||
|
|
@ -0,0 +1,641 @@
|
||||||
|
<!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>/<slug>/</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 & 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('&', '&').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) {
|
||||||
|
// 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>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Scripts
|
||||||
|
|
||||||
|
- **`deploy-svelte.sh`** — deployed den SvelteKit-Build aus `app/build/` nach
|
||||||
|
`svelte.joerg-lohrer.de` via FTPS. Benötigt `.env.local` im Repo-Root mit
|
||||||
|
den Variablen `SVELTE_FTP_HOST`, `SVELTE_FTP_USER`, `SVELTE_FTP_PASS`,
|
||||||
|
`SVELTE_FTP_REMOTE_PATH`. Aufruf:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd app && npm run build && cd .. && ./scripts/deploy-svelte.sh
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy: SvelteKit-Build nach svelte.joerg-lohrer.de per FTPS.
|
||||||
|
# Credentials kommen aus ./.env.local (gitignored), Variablen-Prefix SVELTE_FTP_.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if [ ! -f .env.local ]; then
|
||||||
|
echo "FEHLER: .env.local fehlt — Credentials ergänzen (siehe .env.example)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# nur SVELTE_FTP_* exportieren (via Tempfile — process substitution ist nicht
|
||||||
|
# überall verfügbar, je nach Shell/Sandbox).
|
||||||
|
_ENV_TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$_ENV_TMP"' EXIT
|
||||||
|
grep -E '^SVELTE_FTP_' .env.local > "$_ENV_TMP" || true
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$_ENV_TMP"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
for v in SVELTE_FTP_HOST SVELTE_FTP_USER SVELTE_FTP_PASS SVELTE_FTP_REMOTE_PATH; do
|
||||||
|
if [ -z "${!v:-}" ]; then
|
||||||
|
echo "FEHLER: $v fehlt in .env.local." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
BUILD_DIR="$ROOT/app/build"
|
||||||
|
if [ ! -d "$BUILD_DIR" ]; then
|
||||||
|
echo "FEHLER: app/build nicht vorhanden. Bitte vorher 'npm run build' in app/ ausführen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Lade Build von $BUILD_DIR nach ftp://$SVELTE_FTP_HOST$SVELTE_FTP_REMOTE_PATH"
|
||||||
|
|
||||||
|
# pro Datei ein curl-Upload (zuverlässig auf macOS ohne lftp)
|
||||||
|
find "$BUILD_DIR" -type f -print0 | while IFS= read -r -d '' local_file; do
|
||||||
|
rel="${local_file#$BUILD_DIR/}"
|
||||||
|
remote="ftp://$SVELTE_FTP_HOST${SVELTE_FTP_REMOTE_PATH%/}/$rel"
|
||||||
|
echo " → $rel"
|
||||||
|
# --tls-max 1.2: All-Inkl/Kasserver FTPS schließt bei TLS 1.3 die Data-
|
||||||
|
# Connection mit "426 Transfer aborted" — mit 1.2 läuft es sauber durch.
|
||||||
|
curl -sSf --ssl-reqd --tls-max 1.2 --ftp-create-dirs \
|
||||||
|
--retry 3 --retry-delay 2 --retry-all-errors \
|
||||||
|
--connect-timeout 15 \
|
||||||
|
--user "$SVELTE_FTP_USER:$SVELTE_FTP_PASS" \
|
||||||
|
-T "$local_file" "$remote"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Upload fertig. Live-Check:"
|
||||||
|
curl -sIL "https://svelte.joerg-lohrer.de/" | head -5
|
||||||
Loading…
Reference in New Issue