Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f14e4f150 | |||
| 6b2b7245f4 | |||
| 3318fe9e17 | |||
| e4804ce673 |
@@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
let {
|
||||
pageId,
|
||||
environment,
|
||||
targetId = "comments-section",
|
||||
class: cls = "",
|
||||
}: {
|
||||
pageId: string;
|
||||
environment?: string;
|
||||
targetId?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let count = $state<number | null>(null);
|
||||
|
||||
const cmsBase = (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
"http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
$effect(() => {
|
||||
const url = `${cmsBase}/api/comments/${encodeURIComponent(pageId)}/count${
|
||||
environment ? `?_environment=${encodeURIComponent(environment)}` : ""
|
||||
}`;
|
||||
let cancelled = false;
|
||||
fetch(url)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
count = typeof d?.count === "number" ? d.count : 0;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) count = 0;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
const el = document.getElementById(targetId) as HTMLDetailsElement | null;
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
el.open = true;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="#{targetId}"
|
||||
onclick={onClick}
|
||||
aria-label="Zu Kommentaren springen"
|
||||
class="no-underline! inline-flex items-center gap-1 text-xs font-medium transition-colors {cls}"
|
||||
>
|
||||
<Icon icon="mdi:comment-outline" class="size-4" />
|
||||
<span class="tabular-nums">{count ?? "…"}</span>
|
||||
</a>
|
||||
@@ -2,6 +2,7 @@
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
|
||||
let {
|
||||
pageId,
|
||||
@@ -15,6 +16,8 @@
|
||||
bodyMax?: number;
|
||||
} = $props();
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
type Status = "approved" | "pending" | "spam";
|
||||
|
||||
type PublicComment = {
|
||||
@@ -187,7 +190,7 @@
|
||||
}
|
||||
|
||||
async function deleteComment(id: string) {
|
||||
if (!confirm("Kommentar löschen?")) return;
|
||||
if (!confirm(t(T.comments_confirm_delete))) return;
|
||||
try {
|
||||
const r = await fetch(`${apiUrl()}`.replace(`/${pageId}`, `/${pageId}/${id}`), {
|
||||
method: "DELETE",
|
||||
@@ -310,12 +313,12 @@
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-zinc-800 px-3 py-1 font-medium text-white hover:bg-zinc-700"
|
||||
>Speichern</button>
|
||||
>{t(T.comments_save)}</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEdit}
|
||||
class="text-zinc-500 hover:text-zinc-900"
|
||||
>Abbrechen</button>
|
||||
>{t(T.comments_cancel)}</button>
|
||||
<span class="ml-auto text-zinc-400 tabular-nums">{editBody.length} / {bodyMax}</span>
|
||||
</div>
|
||||
{#if editError}<p class="text-red-700 text-xs mt-1">{editError}</p>{/if}
|
||||
@@ -330,7 +333,7 @@
|
||||
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="mdi:pencil-outline" class="size-3" />
|
||||
Bearbeiten
|
||||
{t(T.comments_edit)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -338,7 +341,7 @@
|
||||
class="text-xs text-zinc-600 hover:text-red-700 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="mdi:trash-can-outline" class="size-3" />
|
||||
Löschen
|
||||
{t(T.comments_delete)}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -349,7 +352,7 @@
|
||||
<div class="flex flex-wrap items-baseline gap-2 text-sm">
|
||||
<strong class="font-medium text-zinc-900">{c.author}</strong>
|
||||
{#if c.can_edit}
|
||||
<span class="text-[10px] uppercase tracking-wide text-zinc-400">du</span>
|
||||
<span class="text-[10px] uppercase tracking-wide text-zinc-400">{t(T.comments_self_marker)}</span>
|
||||
{/if}
|
||||
<time
|
||||
class="text-zinc-500 text-xs"
|
||||
@@ -357,12 +360,12 @@
|
||||
title={fmtAbs(c.created_at)}
|
||||
>{fmtRel(c.created_at)}</time>
|
||||
{#if c.updated_at}
|
||||
<span class="text-zinc-400 text-xs" title={fmtAbs(c.updated_at)}>(bearbeitet)</span>
|
||||
<span class="text-zinc-400 text-xs" title={fmtAbs(c.updated_at)}>{t(T.comments_edited)}</span>
|
||||
{/if}
|
||||
{#if c.status === "pending"}
|
||||
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-800">
|
||||
<Icon icon="mdi:clock-outline" class="size-3" />
|
||||
wartet
|
||||
{t(T.comments_pending_short)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -370,7 +373,7 @@
|
||||
|
||||
<div class="comments {cls}">
|
||||
{#if loading && comments.length === 0}
|
||||
<ul class="space-y-4 list-none p-0" aria-busy="true" aria-label="Lade Kommentare">
|
||||
<ul class="space-y-4 list-none p-0" aria-busy="true" aria-label={t(T.comments_loading)}>
|
||||
{#each Array(2) as _, i (i)}
|
||||
<li class="flex gap-3 list-none!">
|
||||
<div class="size-9 shrink-0 rounded-full bg-zinc-200 animate-pulse"></div>
|
||||
@@ -386,20 +389,20 @@
|
||||
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<Icon icon="mdi:alert-circle-outline" class="size-5 shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">Kommentare konnten nicht geladen werden.</p>
|
||||
<p class="font-medium">{t(T.comments_load_error_title)}</p>
|
||||
<p class="mt-1 text-xs text-red-700">{loadError}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={loadComments}
|
||||
class="text-xs text-red-800 underline hover:no-underline"
|
||||
>Erneut versuchen</button>
|
||||
>{t(T.comments_retry)}</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if topLevel.length === 0}
|
||||
<div class="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<Icon icon="mdi:comment-text-outline" class="size-10 text-zinc-300" />
|
||||
<p class="text-sm text-zinc-500">Noch keine Kommentare. Sei der erste.</p>
|
||||
<div class="flex flex-col items-center gap-2 py-8 text-center sm:flex-row sm:items-center sm:text-left sm:py-4 sm:gap-3">
|
||||
<Icon icon="mdi:comment-text-outline" class="size-10 text-zinc-300 shrink-0" />
|
||||
<p class="text-sm text-zinc-500">{t(T.comments_empty)}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-5 mb-8 list-none p-0">
|
||||
@@ -423,10 +426,12 @@
|
||||
>
|
||||
<Icon icon="mdi:reply" class="size-3" />
|
||||
{replyTo === c.id
|
||||
? "Abbrechen"
|
||||
: replies.length > 0
|
||||
? `${replies.length} ${replies.length === 1 ? 'Antwort' : 'Antworten'}`
|
||||
: "Antworten"}
|
||||
? t(T.comments_cancel)
|
||||
: replies.length === 0
|
||||
? t(T.comments_reply)
|
||||
: replies.length === 1
|
||||
? t(T.comments_reply_count_one)
|
||||
: t(T.comments_reply_count_other, { count: replies.length })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -435,7 +440,7 @@
|
||||
<input
|
||||
type="text"
|
||||
bind:value={replyAuthor}
|
||||
placeholder="Dein Name"
|
||||
placeholder={t(T.comments_name_placeholder)}
|
||||
required
|
||||
maxlength="80"
|
||||
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||
@@ -444,7 +449,7 @@
|
||||
bind:value={replyBody}
|
||||
use:autosize
|
||||
onkeydown={submitOnCmdEnter}
|
||||
placeholder="Deine Antwort"
|
||||
placeholder={t(T.comments_reply_placeholder)}
|
||||
required
|
||||
rows="2"
|
||||
maxlength={bodyMax}
|
||||
@@ -456,7 +461,7 @@
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="px-3 py-1.5 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50"
|
||||
>Antwort senden</button>
|
||||
>{t(T.comments_reply_send)}</button>
|
||||
<span class="ml-auto text-xs text-zinc-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
|
||||
</div>
|
||||
</form>
|
||||
@@ -486,11 +491,11 @@
|
||||
{/if}
|
||||
|
||||
<form onsubmit={onSubmit} class="border-t border-zinc-200 pt-5 space-y-3">
|
||||
<h3 class="font-medium text-sm text-zinc-900">Kommentar schreiben</h3>
|
||||
<h3 class="font-medium text-sm text-zinc-900">{t(T.comments_form_title)}</h3>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={author}
|
||||
placeholder="Dein Name"
|
||||
placeholder={t(T.comments_name_placeholder)}
|
||||
required
|
||||
maxlength="80"
|
||||
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||
@@ -499,7 +504,7 @@
|
||||
bind:value={body}
|
||||
use:autosize
|
||||
onkeydown={submitOnCmdEnter}
|
||||
placeholder="Schreibe etwas Konstruktives. Cmd/Ctrl+Enter sendet."
|
||||
placeholder={t(T.comments_body_placeholder)}
|
||||
required
|
||||
rows="3"
|
||||
maxlength={bodyMax}
|
||||
@@ -522,19 +527,21 @@
|
||||
disabled={submitting || !body.trim() || !author.trim()}
|
||||
class="px-4 py-2 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? "Sende …" : "Senden"}
|
||||
{submitting ? t(T.comments_sending) : t(T.comments_send)}
|
||||
</button>
|
||||
{#if requireApproval}
|
||||
<span class="text-zinc-500 inline-flex items-center gap-1">
|
||||
<Icon icon="mdi:shield-check-outline" class="size-3.5" />
|
||||
Wird vor Veröffentlichung geprüft.
|
||||
{t(T.comments_approval_hint)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="ml-auto text-zinc-400 tabular-nums">{body.length} / {bodyMax}</span>
|
||||
</div>
|
||||
{#if totalPending > 0}
|
||||
<p class="text-xs text-zinc-500 italic">
|
||||
{totalPending} {totalPending === 1 ? 'Kommentar wartet' : 'Kommentare warten'} noch auf Freigabe (nur für dich sichtbar).
|
||||
{totalPending === 1
|
||||
? t(T.comments_pending_summary_one)
|
||||
: t(T.comments_pending_summary_other, { count: totalPending })}
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/iconify-offline";
|
||||
import { env } from "$env/dynamic/public";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
|
||||
let {
|
||||
url,
|
||||
title,
|
||||
readingTimeMinutes = null,
|
||||
commentPageId = null,
|
||||
commentEnvironment,
|
||||
commentTargetId = "comments-section",
|
||||
class: cls = "",
|
||||
}: {
|
||||
/** Absolute URL of the page being shared. */
|
||||
url: string;
|
||||
/** Page headline — used by the native share-sheet. */
|
||||
title: string;
|
||||
/** Estimated reading time. `null` hides the badge. */
|
||||
readingTimeMinutes?: number | null;
|
||||
/** Page id for the comment plugin. `null` hides the count badge. */
|
||||
commentPageId?: string | null;
|
||||
/** Optional space override for the comment count fetch. */
|
||||
commentEnvironment?: string;
|
||||
/** DOM id of the comment accordion (used by the count-badge click). */
|
||||
commentTargetId?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
// ── Comment count fetch (only when commentPageId is set) ────────────────
|
||||
let commentCount = $state<number | null>(null);
|
||||
const cmsBase = (
|
||||
env.PUBLIC_CMS_URL ||
|
||||
import.meta.env.PUBLIC_CMS_URL ||
|
||||
"http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
$effect(() => {
|
||||
if (!commentPageId) {
|
||||
commentCount = null;
|
||||
return;
|
||||
}
|
||||
const fetchUrl = `${cmsBase}/api/comments/${encodeURIComponent(commentPageId)}/count${
|
||||
commentEnvironment
|
||||
? `?_environment=${encodeURIComponent(commentEnvironment)}`
|
||||
: ""
|
||||
}`;
|
||||
let cancelled = false;
|
||||
fetch(fetchUrl)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
commentCount = typeof d?.count === "number" ? d.count : 0;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) commentCount = 0;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
function onCommentClick(e: MouseEvent) {
|
||||
const el = document.getElementById(
|
||||
commentTargetId,
|
||||
) as HTMLDetailsElement | null;
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
el.open = true;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
let copied = $state(false);
|
||||
let shared = $state(false);
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let shareTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Share button only renders when the browser actually has a native share-
|
||||
// sheet — otherwise it would just be a duplicate of "Link kopieren".
|
||||
// SSR has no `navigator`, so this stays false until client mount.
|
||||
let canShare = $state(false);
|
||||
$effect(() => {
|
||||
canShare =
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof (navigator as Navigator & { share?: unknown }).share ===
|
||||
"function";
|
||||
});
|
||||
|
||||
function flashCopied() {
|
||||
copied = true;
|
||||
if (copyTimer) clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
|
||||
function flashShared() {
|
||||
shared = true;
|
||||
if (shareTimer) clearTimeout(shareTimer);
|
||||
shareTimer = setTimeout(() => (shared = false), 1500);
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
flashCopied();
|
||||
} catch {
|
||||
// Fallback: temporary <input> + execCommand for older browsers.
|
||||
const el = document.createElement("input");
|
||||
el.value = url;
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
flashCopied();
|
||||
} catch {
|
||||
// give up — clipboard not available
|
||||
} finally {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function share() {
|
||||
const nav = navigator as Navigator & {
|
||||
share?: (data: ShareData) => Promise<void>;
|
||||
};
|
||||
if (typeof nav.share !== "function") return;
|
||||
try {
|
||||
await nav.share({ title, url });
|
||||
flashShared();
|
||||
} catch {
|
||||
// user cancelled — no fallback, copy-link button covers that case
|
||||
}
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Post-actions toolbar. Each item is a rounded-full pill with a subtle
|
||||
border so they read as visually equal "badges". Order:
|
||||
[info badges] | [action buttons]
|
||||
i.e. info-style items (count, reading time) stand left/right, the
|
||||
three icon-only actions cluster in the middle.
|
||||
-->
|
||||
<div class="inline-flex flex-wrap items-center gap-2 {cls}">
|
||||
{#if commentPageId}
|
||||
<a
|
||||
href="#{commentTargetId}"
|
||||
onclick={onCommentClick}
|
||||
aria-label={t(T.comments_jump_to)}
|
||||
title={t(T.comments_jump_to)}
|
||||
class="no-underline! inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700! hover:bg-zinc-50 hover:border-zinc-300 transition-colors"
|
||||
>
|
||||
<Icon icon="mdi:comment-outline" class="size-3.5" />
|
||||
<span class="tabular-nums">{commentCount ?? "…"}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="inline-flex items-center rounded-full border border-zinc-200 bg-white overflow-hidden divide-x divide-zinc-200 print:hidden">
|
||||
{#if canShare}
|
||||
<button
|
||||
type="button"
|
||||
onclick={share}
|
||||
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors"
|
||||
aria-label={t(T.post_action_share)}
|
||||
title={t(T.post_action_share)}
|
||||
>
|
||||
<Icon
|
||||
icon={shared ? "mdi:check" : "mdi:share-variant-outline"}
|
||||
class="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyLink}
|
||||
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors"
|
||||
aria-label={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
|
||||
title={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
|
||||
>
|
||||
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={print}
|
||||
class="inline-flex items-center px-2.5 py-1 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 transition-colors"
|
||||
aria-label={t(T.post_action_print)}
|
||||
title={t(T.post_action_print)}
|
||||
>
|
||||
<Icon icon="mdi:printer-outline" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs text-zinc-500"
|
||||
title={t(T.post_action_reading_time, { minutes: readingTimeMinutes })}
|
||||
>
|
||||
<Icon icon="mdi:clock-outline" class="size-3.5" />
|
||||
<span class="tabular-nums"
|
||||
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6,6 +6,9 @@
|
||||
import EventBadges from "./EventBadges.svelte";
|
||||
import RustyImage from "./RustyImage.svelte";
|
||||
import Card from "./Card.svelte";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
type PostWithImage = PostEntry & {
|
||||
_resolvedImageUrl?: string;
|
||||
@@ -98,8 +101,8 @@
|
||||
<div class="absolute top-2 left-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 text-[.65rem] font-semibold tabular-nums text-zinc-700 shadow-sm"
|
||||
aria-label="{commentCount} Kommentare"
|
||||
title="{commentCount} Kommentare"
|
||||
aria-label={t(T.comments_count_aria, { count: commentCount })}
|
||||
title={t(T.comments_count_aria, { count: commentCount })}
|
||||
>
|
||||
<Icon icon="mdi:comment-outline" class="size-3" />
|
||||
{commentCount}
|
||||
|
||||
@@ -133,6 +133,41 @@ const TRANSLATION_KEYS = [
|
||||
"post_map_activate",
|
||||
"post_map_open_osm",
|
||||
"post_comments",
|
||||
// Comment plugin (Comments.svelte / PostCard count pill / PostActions toolbar).
|
||||
"comments_loading",
|
||||
"comments_load_error_title",
|
||||
"comments_retry",
|
||||
"comments_empty",
|
||||
"comments_edit",
|
||||
"comments_delete",
|
||||
"comments_save",
|
||||
"comments_cancel",
|
||||
"comments_confirm_delete",
|
||||
"comments_reply",
|
||||
"comments_reply_count_one",
|
||||
"comments_reply_count_other",
|
||||
"comments_reply_send",
|
||||
"comments_send",
|
||||
"comments_sending",
|
||||
"comments_form_title",
|
||||
"comments_name_placeholder",
|
||||
"comments_body_placeholder",
|
||||
"comments_reply_placeholder",
|
||||
"comments_approval_hint",
|
||||
"comments_edited",
|
||||
"comments_pending_short",
|
||||
"comments_pending_full",
|
||||
"comments_self_marker",
|
||||
"comments_pending_summary_one",
|
||||
"comments_pending_summary_other",
|
||||
"comments_count_aria",
|
||||
"comments_jump_to",
|
||||
// Post actions toolbar (share / copy link / print / reading time).
|
||||
"post_action_share",
|
||||
"post_action_copy",
|
||||
"post_action_copied",
|
||||
"post_action_print",
|
||||
"post_action_reading_time",
|
||||
] as const;
|
||||
|
||||
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
||||
|
||||
@@ -236,6 +236,45 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
|
||||
jsonLd.push(eventLd);
|
||||
}
|
||||
|
||||
// Reading-time estimate. ~200 words per minute for German non-fiction.
|
||||
// Pulls text from headline + body markdown AND from referenced row blocks
|
||||
// (Termin-Posts have an empty body; their text lives in row1Content
|
||||
// markdown/text references). Walks the post tree once, collecting any
|
||||
// `content` / `body` / `text` string fields below row*Content. HTML tags
|
||||
// get stripped before the word count.
|
||||
const readingTimeMinutes = (() => {
|
||||
const parts: string[] = [contentHeadlineHtml, contentBodyHtml];
|
||||
const collect = (node: unknown) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) collect(item);
|
||||
return;
|
||||
}
|
||||
const obj = node as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === "string") {
|
||||
if (key === "content" || key === "body" || key === "text") {
|
||||
parts.push(value);
|
||||
}
|
||||
} else {
|
||||
collect(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect((post as { row1Content?: unknown }).row1Content);
|
||||
collect((post as { row2Content?: unknown }).row2Content);
|
||||
collect((post as { row3Content?: unknown }).row3Content);
|
||||
|
||||
const plain = parts
|
||||
.join(" ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!plain) return 0;
|
||||
const wordCount = plain.split(" ").length;
|
||||
return Math.max(1, Math.ceil(wordCount / 200));
|
||||
})();
|
||||
|
||||
return {
|
||||
post,
|
||||
preview: !!preview,
|
||||
@@ -254,6 +293,8 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
|
||||
mapEmbedUrl,
|
||||
mapLinkUrl,
|
||||
commentPageId: showCommentSection ? slug : null,
|
||||
canonicalUrl: canonical,
|
||||
readingTimeMinutes,
|
||||
translations,
|
||||
seoTitle: post.seoTitle ?? postHeadline,
|
||||
seoDescription: postDescription,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import EventBadges from "$lib/components/EventBadges.svelte";
|
||||
import EventMap from "$lib/components/EventMap.svelte";
|
||||
import Accordion from "$lib/components/Accordion.svelte";
|
||||
import CommentCountBadge from "$lib/components/CommentCountBadge.svelte";
|
||||
import Comments from "$lib/components/Comments.svelte";
|
||||
import PostActions from "$lib/components/PostActions.svelte";
|
||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||
import { t, T } from "$lib/translations";
|
||||
import type { PageData } from "./$types";
|
||||
@@ -24,10 +24,10 @@
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="md:flex gap-2 items-end">
|
||||
<div class="my-4 md:flex md:items-start md:gap-4">
|
||||
{#if data.rawPostImageUrl}
|
||||
<div
|
||||
class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg aspect-square"
|
||||
class="w-[150px] shrink-0 overflow-hidden border border-white ring-1 ring-black/50 shadow-lg aspect-square"
|
||||
>
|
||||
<RustyImage
|
||||
src={data.rawPostImageUrl}
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
{:else if data.postImageUrl}
|
||||
<div
|
||||
class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg aspect-square"
|
||||
class="w-[150px] shrink-0 overflow-hidden border border-white ring-1 ring-black/50 shadow-lg aspect-square"
|
||||
>
|
||||
<img
|
||||
src={data.postImageUrl}
|
||||
@@ -57,32 +57,31 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
|
||||
{#if data.postDate || data.commentPageId}
|
||||
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
|
||||
|
||||
<!-- Right column: stacked rows with consistent rhythm -->
|
||||
<div class="mt-3 md:mt-0 min-w-0 flex flex-col gap-3">
|
||||
{#if data.postDate}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Tag label={data.postDate} variant="date" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="flex flex-nowrap gap-2 items-center shrink-0 p-1 pb-4 -ml-1 overflow-x-auto overflow-y-hidden"
|
||||
>
|
||||
{#if (data.post.postTag ?? []).length > 0}
|
||||
<div class="flex flex-wrap items-center gap-1.5 -ml-0.5">
|
||||
<Tags
|
||||
tags={data.post.postTag ?? []}
|
||||
variant="green"
|
||||
tagBase="/posts/tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{#if data.commentPageId}
|
||||
<CommentCountBadge
|
||||
pageId={data.commentPageId}
|
||||
targetId="comments-section"
|
||||
/>
|
||||
{/if}
|
||||
<PostActions
|
||||
url={data.canonicalUrl}
|
||||
title={data.seoTitle}
|
||||
readingTimeMinutes={data.readingTimeMinutes}
|
||||
commentPageId={data.commentPageId}
|
||||
commentTargetId="comments-section"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article
|
||||
|
||||
Reference in New Issue
Block a user