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