feat: replace Cusdis with own comment plugin
Deploy / verify (push) Failing after 45s
Deploy / deploy (push) Has been skipped

Drops the Cusdis embed (postCommentSlot in pageConfig) in favour of the
new RustyCMS comment plugin. New Comments.svelte renders the thread,
form, replies and inline edit/delete via /api/comments. Count-badge near
the post date links to the accordion and uses /api/comments/:id/count.

Accordion gets a `lazy` mode so the comment iframe / fetch happens only
on first open (avoids the previous "empty on first render" issue with
hidden details).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-04-28 11:17:10 +02:00
parent 9b4191151c
commit febc027e6b
5 changed files with 498 additions and 39 deletions
+25 -9
View File
@@ -3,27 +3,43 @@
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
interface Props {
label: string;
open?: boolean;
lazy?: boolean;
id?: string;
class?: string;
onToggleEvent?: (e: Event) => void;
children: Snippet;
}
let {
label,
open = false,
lazy = false,
id = undefined,
class: cls = "",
onToggleEvent,
children,
}: {
label: string;
open?: boolean;
id?: string;
class?: string;
children: Snippet;
} = $props();
}: Props = $props();
let everOpened = $state(false);
const mounted = $derived(!lazy || open || everOpened);
function onToggle(e: Event) {
if ((e.target as HTMLDetailsElement).open) everOpened = true;
onToggleEvent?.(e);
}
</script>
<details {id} {open} class="pt-4 border-t border-zinc-200 group {cls}">
<details {id} {open} ontoggle={onToggle} class="pt-4 border-t border-zinc-200 group {cls}">
<summary class="flex cursor-pointer list-none items-center gap-2 text-sm font-medium select-none">
<Icon icon="mdi:chevron-right" class="size-4 shrink-0 transition-transform group-open:rotate-90" />
{label}
</summary>
<div class="mt-4">
{@render children()}
{#if mounted}
{@render children()}
{/if}
</div>
</details>
@@ -0,0 +1,62 @@
<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="inline-flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 transition-colors {cls}"
>
<Icon icon="mdi:comment-outline" class="size-4" />
<span class="tabular-nums">{count ?? "…"}</span>
</a>
+394
View File
@@ -0,0 +1,394 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
let {
pageId,
environment,
class: cls = "",
}: {
pageId: string;
environment?: string;
class?: string;
} = $props();
type Status = "approved" | "pending" | "spam";
type PublicComment = {
id: string;
parent_id?: string;
author: string;
body: string;
created_at: string;
updated_at?: string;
status: Status;
can_edit: boolean;
};
type ListResponse = {
comments: PublicComment[];
session_id: string;
require_approval: boolean;
};
const cmsBase = (
env.PUBLIC_CMS_URL ||
import.meta.env.PUBLIC_CMS_URL ||
"http://localhost:3000"
).replace(/\/$/, "");
const apiUrl = (suffix = "") =>
`${cmsBase}/api/comments/${encodeURIComponent(pageId)}${suffix}${
environment ? `?_environment=${encodeURIComponent(environment)}` : ""
}`;
let comments = $state<PublicComment[]>([]);
let requireApproval = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let author = $state("");
let body = $state("");
let website = $state(""); // honeypot
let submitting = $state(false);
let submitError = $state<string | null>(null);
let replyTo = $state<string | null>(null);
let replyAuthor = $state("");
let replyBody = $state("");
let replyError = $state<string | null>(null);
let editingId = $state<string | null>(null);
let editBody = $state("");
let editError = $state<string | null>(null);
$effect(() => {
loadComments();
});
async function loadComments() {
loading = true;
loadError = null;
try {
const r = await fetch(apiUrl(), { credentials: "include" });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = (await r.json()) as ListResponse;
comments = data.comments;
requireApproval = data.require_approval;
} catch (e) {
loadError = e instanceof Error ? e.message : "Load failed";
} finally {
loading = false;
}
}
async function postComment(authorName: string, text: string, parentId: string | null) {
const r = await fetch(apiUrl(), {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
author: authorName,
body: text,
parent_id: parentId,
website,
}),
});
if (!r.ok) {
const msg = await r.text().catch(() => "");
throw new Error(msg || `HTTP ${r.status}`);
}
return (await r.json()) as PublicComment;
}
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
if (!author.trim() || !body.trim() || submitting) return;
submitting = true;
submitError = null;
try {
await postComment(author, body, null);
body = "";
await loadComments();
} catch (e) {
submitError = e instanceof Error ? e.message : "Send failed";
} finally {
submitting = false;
}
}
async function onReply(e: SubmitEvent, parentId: string) {
e.preventDefault();
if (!replyAuthor.trim() || !replyBody.trim() || submitting) return;
submitting = true;
replyError = null;
try {
await postComment(replyAuthor, replyBody, parentId);
replyBody = "";
replyTo = null;
await loadComments();
} catch (e) {
replyError = e instanceof Error ? e.message : "Send failed";
} finally {
submitting = false;
}
}
function startEdit(c: PublicComment) {
editingId = c.id;
editBody = c.body;
editError = null;
}
function cancelEdit() {
editingId = null;
editBody = "";
editError = null;
}
async function saveEdit(id: string) {
if (!editBody.trim()) return;
try {
const r = await fetch(`${apiUrl()}`.replace(`/${pageId}`, `/${pageId}/${id}`), {
method: "PATCH",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ body: editBody }),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
await loadComments();
cancelEdit();
} catch (e) {
editError = e instanceof Error ? e.message : "Edit failed";
}
}
async function deleteComment(id: string) {
if (!confirm("Kommentar löschen?")) return;
try {
const r = await fetch(`${apiUrl()}`.replace(`/${pageId}`, `/${pageId}/${id}`), {
method: "DELETE",
credentials: "include",
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
await loadComments();
} catch (e) {
loadError = e instanceof Error ? e.message : "Delete failed";
}
}
function fmtDate(iso: string): string {
try {
return new Date(iso).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return iso;
}
}
const topLevel = $derived(comments.filter((c) => !c.parent_id));
function repliesOf(parentId: string) {
return comments.filter((c) => c.parent_id === parentId);
}
</script>
<div class="comments {cls}">
{#if loading && comments.length === 0}
<p class="text-sm text-zinc-500">Lade Kommentare …</p>
{:else if loadError}
<p class="text-sm text-red-700">Fehler: {loadError}</p>
{:else}
{#if topLevel.length === 0}
<p class="text-sm text-zinc-500 mb-4">Noch keine Kommentare. Sei der erste.</p>
{/if}
<ul class="space-y-4 mb-8 list-none p-0">
{#each topLevel as c (c.id)}
<li class="border-l-2 border-zinc-200 pl-4">
<div class="flex items-baseline gap-2 text-sm">
<strong class="font-medium">{c.author}</strong>
<time class="text-zinc-500">{fmtDate(c.created_at)}</time>
{#if c.updated_at}
<span class="text-zinc-400 text-xs">(bearbeitet)</span>
{/if}
{#if c.status === "pending"}
<span class="text-amber-700 text-xs">(wartet auf Freigabe)</span>
{/if}
</div>
{#if editingId === c.id}
<textarea
bind:value={editBody}
class="w-full mt-2 p-2 border border-zinc-300 rounded text-sm"
rows="3"
></textarea>
{#if editError}<p class="text-red-700 text-xs mt-1">{editError}</p>{/if}
<div class="mt-1 flex gap-2">
<button
type="button"
onclick={() => saveEdit(c.id)}
class="text-sm text-blue-700 hover:underline"
>Speichern</button>
<button
type="button"
onclick={cancelEdit}
class="text-sm text-zinc-500 hover:underline"
>Abbrechen</button>
</div>
{:else}
<p class="mt-1 whitespace-pre-wrap text-sm">{c.body}</p>
{#if c.can_edit}
<div class="mt-1 flex gap-3">
<button
type="button"
onclick={() => startEdit(c)}
class="text-xs text-zinc-600 hover:text-zinc-900"
>Bearbeiten</button>
<button
type="button"
onclick={() => deleteComment(c.id)}
class="text-xs text-zinc-600 hover:text-red-700"
>Löschen</button>
</div>
{/if}
{/if}
<div class="mt-2">
<button
type="button"
onclick={() => (replyTo = replyTo === c.id ? null : c.id)}
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
>
<Icon icon="mdi:reply" class="size-3" />
Antworten
</button>
</div>
{#if replyTo === c.id}
<form
onsubmit={(e) => onReply(e, c.id)}
class="mt-2 ml-4 space-y-2"
>
<input
type="text"
bind:value={replyAuthor}
placeholder="Name"
required
maxlength="80"
class="w-full p-2 border border-zinc-300 rounded text-sm"
/>
<textarea
bind:value={replyBody}
placeholder="Antwort"
required
rows="2"
class="w-full p-2 border border-zinc-300 rounded text-sm"
></textarea>
{#if replyError}<p class="text-red-700 text-xs">{replyError}</p>{/if}
<button
type="submit"
disabled={submitting}
class="px-3 py-1 bg-zinc-800 text-white rounded text-sm hover:bg-zinc-700 disabled:opacity-50"
>Senden</button>
</form>
{/if}
{@const replies = repliesOf(c.id)}
{#if replies.length > 0}
<ul class="mt-3 ml-4 space-y-3 list-none p-0">
{#each replies as r (r.id)}
<li class="border-l-2 border-zinc-100 pl-3">
<div class="flex items-baseline gap-2 text-sm">
<strong class="font-medium">{r.author}</strong>
<time class="text-zinc-500">{fmtDate(r.created_at)}</time>
{#if r.updated_at}
<span class="text-zinc-400 text-xs">(bearbeitet)</span>
{/if}
{#if r.status === "pending"}
<span class="text-amber-700 text-xs">(wartet auf Freigabe)</span>
{/if}
</div>
{#if editingId === r.id}
<textarea
bind:value={editBody}
class="w-full mt-2 p-2 border border-zinc-300 rounded text-sm"
rows="2"
></textarea>
{#if editError}<p class="text-red-700 text-xs mt-1">{editError}</p>{/if}
<div class="mt-1 flex gap-2">
<button
type="button"
onclick={() => saveEdit(r.id)}
class="text-sm text-blue-700 hover:underline"
>Speichern</button>
<button
type="button"
onclick={cancelEdit}
class="text-sm text-zinc-500 hover:underline"
>Abbrechen</button>
</div>
{:else}
<p class="mt-1 whitespace-pre-wrap text-sm">{r.body}</p>
{#if r.can_edit}
<div class="mt-1 flex gap-3">
<button
type="button"
onclick={() => startEdit(r)}
class="text-xs text-zinc-600 hover:text-zinc-900"
>Bearbeiten</button>
<button
type="button"
onclick={() => deleteComment(r.id)}
class="text-xs text-zinc-600 hover:text-red-700"
>Löschen</button>
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
</li>
{/each}
</ul>
<form onsubmit={onSubmit} class="space-y-2 border-t border-zinc-200 pt-4">
<h3 class="font-medium text-sm">Kommentar schreiben</h3>
<input
type="text"
bind:value={author}
placeholder="Name"
required
maxlength="80"
class="w-full p-2 border border-zinc-300 rounded text-sm"
/>
<textarea
bind:value={body}
placeholder="Dein Kommentar"
required
rows="4"
class="w-full p-2 border border-zinc-300 rounded text-sm"
></textarea>
<input
type="text"
bind:value={website}
tabindex="-1"
autocomplete="off"
aria-hidden="true"
class="absolute left-[-9999px]"
/>
{#if submitError}<p class="text-red-700 text-xs">{submitError}</p>{/if}
{#if requireApproval}
<p class="text-xs text-zinc-500">Kommentare werden vor Veröffentlichung geprüft.</p>
{/if}
<button
type="submit"
disabled={submitting}
class="px-4 py-2 bg-zinc-800 text-white rounded text-sm hover:bg-zinc-700 disabled:opacity-50"
>Senden</button>
</form>
{/if}
</div>
+2 -12
View File
@@ -1,5 +1,5 @@
import type { PageServerLoad } from './$types';
import { getPostBySlug, getPageConfigBySlug, getPageConfigs } from '$lib/cms';
import { getPostBySlug } from '$lib/cms';
import {
resolveContentImages,
ensureTransformedImage,
@@ -135,16 +135,6 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
? `https://www.openstreetmap.org/search?query=${encodeURIComponent(eventLocation.text)}`
: null;
const pageConfig =
(await getPageConfigBySlug('page-config-default', { locale: 'de' })) ??
(await getPageConfigs({ locale: 'de', per_page: 1 }))[0] ??
null;
const commentSlotRaw =
(pageConfig as { postCommentSlot?: string } | null)?.postCommentSlot ?? '';
const commentSlot = commentSlotRaw
.replace(/\{\{PAGE_ID\}\}/g, slug)
.replace(/\{\{PAGE_URL\}\}/g, url.href)
.replace(/\{\{PAGE_TITLE\}\}/g, post.headline ?? post.linkName ?? slug);
const showCommentSection = (post as { showCommentSection?: boolean }).showCommentSection !== false;
const jsonLd: Record<string, unknown>[] = [];
@@ -263,7 +253,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
eventLocationText: eventLocation?.text ?? null,
mapEmbedUrl,
mapLinkUrl,
commentSlot: showCommentSection ? commentSlot : '',
commentPageId: showCommentSection ? slug : null,
translations,
seoTitle: post.seoTitle ?? postHeadline,
seoDescription: postDescription,
+15 -18
View File
@@ -5,21 +5,13 @@
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 RustyImage from '$lib/components/RustyImage.svelte';
import { t, T } from '$lib/translations';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
function executeScripts(node: HTMLElement) {
const scripts = node.querySelectorAll("script");
scripts.forEach((old) => {
const s = document.createElement("script");
for (const { name, value } of old.attributes) s.setAttribute(name, value);
s.text = old.textContent ?? "";
old.replaceWith(s);
});
}
</script>
<svelte:head>
@@ -34,7 +26,7 @@
<div class="md:flex gap-2 items-end">
{#if data.rawPostImageUrl}
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg">
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg aspect-square">
<RustyImage
src={data.rawPostImageUrl}
focal={data.postImageFocal}
@@ -49,7 +41,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">
<div class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg aspect-square">
<img
src={data.postImageUrl}
alt=""
@@ -62,9 +54,14 @@
</div>
{/if}
<div class="mt-2 mb-4! md:mb-1 min-w-0 gap-2">
{#if data.postDate}
{#if data.postDate || data.commentPageId}
<div class="flex flex-nowrap gap-2 items-center shrink-0 mb-2">
<Tag label={data.postDate} variant="date" />
{#if data.postDate}
<Tag label={data.postDate} variant="date" />
{/if}
{#if data.commentPageId}
<CommentCountBadge pageId={data.commentPageId} targetId="comments-section" />
{/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">
@@ -148,10 +145,10 @@
/>
{/if}
{#if data.commentSlot}
<Accordion label={t(data.translations, T.post_comments)} class="mt-6">
<div class="p-8 border border-zinc-200 rounded-lg" use:executeScripts>
{@html data.commentSlot}
{#if data.commentPageId}
<Accordion id="comments-section" label={t(data.translations, T.post_comments)} lazy class="mt-6">
<div class="p-8 border border-zinc-200 rounded-lg">
<Comments pageId={data.commentPageId} />
</div>
</Accordion>
{/if}