spa(phase 3, tasks 15-22): routing, komponenten, home, postview
Phase 3 komplett: - Task 15: LoadingOrError-Komponente (loading/error-states, Habla-Fallback) - Task 16: app.html mit CSS-Variablen (light/dark), Base-Typography - Task 17: +layout.svelte mit Container + bootstrapReadRelays onMount - Task 18: ProfileCard-Komponente (Avatar, Name, About, NIP-05, Website) - Task 19: PostCard-Komponente (Thumbnail + Titel/Summary/Datum), responsive - Task 20: +page.svelte als Home (Profil + Liste, Promise.all für beides) - Task 21: PostView-Komponente (Titel, Meta, Cover, Summary, Markdown-Body) - Task 22: [...slug]/+page.ts+svelte — Catch-all-Route mit Legacy-301-Redirect Alle $props()-abhängigen Werte via $derived() (Svelte-5-Runes-Konformität). npm run check: 0 errors, 0 warnings, 592 files. npm run build grün. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dcef74e75c
commit
feb336fc5b
|
|
@ -1,9 +1,51 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<meta name="description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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,148 @@
|
|||
<script lang="ts">
|
||||
import type { NostrEvent } from '$lib/nostr/loaders';
|
||||
import { renderMarkdown } from '$lib/render/markdown';
|
||||
|
||||
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 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));
|
||||
|
||||
$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>
|
||||
|
||||
<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>
|
||||
|
|
@ -1,11 +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>
|
||||
|
|
|
|||
|
|
@ -1,2 +1,50 @@
|
|||
<h1>SvelteKit-SPA bootet</h1>
|
||||
<p>Wird Stück für Stück mit Nostr-Funktionalität gefüllt.</p>
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
|
||||
import { loadPostList, loadProfile } from '$lib/nostr/loaders';
|
||||
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([loadProfile(), 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]) };
|
||||
};
|
||||
Loading…
Reference in New Issue