joerglohrerde/app/src/routes/[...slug]/+page.svelte

265 lines
7.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders'
import Reactions from '$lib/components/Reactions.svelte'
import ReplyList from '$lib/components/ReplyList.svelte'
import ReplyComposer from '$lib/components/ReplyComposer.svelte'
import ExternalClientLinks from '$lib/components/ExternalClientLinks.svelte'
import { renderMarkdown } from '$lib/render/markdown'
import { t } from '$lib/i18n'
import type { SignedEvent } from '$lib/nostr/signer'
let { data } = $props()
const dtag = $derived(data.dtag)
const snapshot = $derived(data.snapshot)
const siteUrl = '__SITE_URL__'
// Site-default-OG-bild aus app/static. Dimensionen sind hartcodiert,
// weil das asset stabil ist (siehe spec §Algorithmus-Schritt 8).
const DEFAULT_OG_IMAGE = `${siteUrl}/joerg-profil-2024.webp`
const DEFAULT_OG_IMAGE_WIDTH = 512
const DEFAULT_OG_IMAGE_HEIGHT = 512
const canonical = $derived(`${siteUrl}/${snapshot?.slug ?? dtag}/`)
const ogImage = $derived(snapshot?.cover_image?.url ?? DEFAULT_OG_IMAGE)
const ogImageAlt = $derived(
snapshot?.cover_image?.alt ?? snapshot?.title ?? 'Jörg Lohrer',
)
const ogImageWidth = $derived(
snapshot?.cover_image?.width ?? (snapshot?.cover_image ? undefined : DEFAULT_OG_IMAGE_WIDTH),
)
const ogImageHeight = $derived(
snapshot?.cover_image?.height ?? (snapshot?.cover_image ? undefined : DEFAULT_OG_IMAGE_HEIGHT),
)
// x-default zeigt auf die DE-variante, weil der autor DE-first arbeitet.
// Bei EN-posts: DE-slug aus translations[] suchen; sonst (DE-post)
// bleibt x-default = canonical.
const xDefaultHref = $derived(
snapshot?.lang === 'en'
? `${siteUrl}/${snapshot.translations.find((tr) => tr.lang === 'de')?.slug ?? snapshot.slug}/`
: canonical,
)
const bodyHtmlPrerendered = $derived(
snapshot ? renderMarkdown(snapshot.content_markdown) : '',
)
let optimisticReplies: NostrEvent[] = $state([])
function handlePublished(signed: SignedEvent) {
optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent]
}
const jsonLd = $derived(
snapshot
? JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: snapshot.title,
description: snapshot.summary,
datePublished: new Date(snapshot.published_at * 1000).toISOString(),
dateModified: new Date(snapshot.created_at * 1000).toISOString(),
author: { '@type': 'Person', name: 'Jörg Lohrer' },
inLanguage: snapshot.lang,
image: ogImage,
mainEntityOfPage: canonical,
})
: '',
)
</script>
<svelte:head>
{#if snapshot}
<title>{snapshot.title} Jörg Lohrer</title>
<meta name="description" content={snapshot.summary} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="article" />
<meta property="og:title" content={snapshot.title} />
<meta property="og:description" content={snapshot.summary} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content={snapshot.lang === 'de' ? 'de_DE' : 'en_US'} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:alt" content={ogImageAlt} />
{#if ogImageWidth}
<meta property="og:image:width" content={String(ogImageWidth)} />
{/if}
{#if ogImageHeight}
<meta property="og:image:height" content={String(ogImageHeight)} />
{/if}
<meta property="article:published_time" content={new Date(snapshot.published_at * 1000).toISOString()} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={snapshot.title} />
<meta name="twitter:description" content={snapshot.summary} />
<meta name="twitter:image" content={ogImage} />
{#each snapshot.translations as alt}
<link rel="alternate" hreflang={alt.lang} href={`${siteUrl}/${alt.slug}/`} />
{/each}
<link rel="alternate" hreflang="x-default" href={xDefaultHref} />
{@html `<script type="application/ld+json">${jsonLd.replace(/<\/script>/gi, '<\\/script>')}</script>`}
{/if}
</svelte:head>
<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>
{#if snapshot}
<article class="post">
<h1 class="post-title">{snapshot.title}</h1>
{#if snapshot.translations.length > 0}
<p class="lang-switch" role="group" aria-label="Article language">
<span class="icon" aria-hidden="true">📖</span>
<span class="btn active" aria-current="true">{snapshot.lang.toUpperCase()}</span>
{#each [...snapshot.translations].sort((a, b) => a.lang.localeCompare(b.lang)) as alt}
<span class="sep" aria-hidden="true">|</span>
<a class="btn" href={`/${alt.slug}/`}>{alt.lang.toUpperCase()}</a>
{/each}
</p>
{/if}
{#if snapshot.cover_image}
<p class="cover">
<img src={snapshot.cover_image.url} alt={snapshot.cover_image.alt ?? ''} />
</p>
{/if}
{#if snapshot.summary}
<p class="summary">{snapshot.summary}</p>
{/if}
<div class="body">{@html bodyHtmlPrerendered}</div>
{#if snapshot.tags.length > 0}
<div class="tags">
{#each snapshot.tags as tag}
<a class="tag" href={`/tag/${encodeURIComponent(tag)}/`}>{tag}</a>
{/each}
</div>
{/if}
<Reactions dtag={snapshot.slug} />
<ExternalClientLinks dtag={snapshot.slug} />
<ReplyComposer dtag={snapshot.slug} eventId={snapshot.event_id} onPublished={handlePublished} />
<ReplyList dtag={snapshot.slug} optimistic={optimisticReplies} />
</article>
{/if}
<style>
.breadcrumb {
font-size: 0.9rem;
margin-bottom: 1rem;
}
.breadcrumb a {
color: var(--accent);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.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;
}
}
.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);
}
.body :global(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.body :global(a) {
color: var(--accent);
word-break: break-word;
}
.body :global(pre) {
background: var(--code-bg);
padding: 0.8rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.88em;
max-width: 100%;
}
.body :global(code) {
background: var(--code-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.92em;
word-break: break-word;
}
.body :global(pre code) {
padding: 0;
background: none;
word-break: normal;
}
.body :global(hr) {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
.body :global(blockquote) {
border-left: 3px solid var(--border);
padding: 0 0 0 1rem;
margin: 1rem 0;
color: var(--muted);
}
.lang-switch {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.88rem;
color: var(--muted);
margin: 0.25rem 0 1rem;
}
.icon {
font-size: 1rem;
line-height: 1;
}
.btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
border-radius: 3px;
padding: 1px 7px;
font-size: 0.8rem;
font-family: inherit;
text-decoration: none;
}
.btn:hover:not(.active) {
color: var(--fg);
}
.btn.active {
color: var(--accent);
border-color: var(--accent);
}
.sep {
opacity: 0.4;
}
.tags {
margin-top: 1.5rem;
}
.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);
}
</style>