0ab2c58eb4
- Header desktop nav: social icons grouped in a wald-tinted pill with labels, active-state (aria-current + bold), opacity hover (no per-item bg) - Mobile overlay: social links as icon+label rows with active-state - Search button: lucide:search, icon-only - Migrate all icons mdi→lucide app-wide (234 refs); brand/language logos (google, whatsapp, mastodon, telegram, language-*) stay on mdi - generate-icons: emit mdi + lucide subsets; add @iconify-json/lucide - iconify-offline: register lucide collection - scripts/icon-map|validate|apply: reusable migration tooling Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
550 lines
18 KiB
Svelte
550 lines
18 KiB
Svelte
<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 {
|
|
pageId,
|
|
environment,
|
|
class: cls = "",
|
|
bodyMax = 4096,
|
|
}: {
|
|
pageId: string;
|
|
environment?: string;
|
|
class?: string;
|
|
bodyMax?: number;
|
|
} = $props();
|
|
|
|
const t = useTranslate();
|
|
|
|
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)}` : ""
|
|
}`;
|
|
|
|
const AUTHOR_KEY = "ww_comment_author";
|
|
|
|
let comments = $state<PublicComment[]>([]);
|
|
let requireApproval = $state(false);
|
|
let loading = $state(true);
|
|
let loadError = $state<string | null>(null);
|
|
|
|
// Anti-instant-bot: Backend rejects POSTs with `client_age_ms` below a
|
|
// threshold (default 3000ms). Captured once when the component mounts.
|
|
const mountedAt = Date.now();
|
|
|
|
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);
|
|
|
|
// Restore name from localStorage on first mount.
|
|
$effect(() => {
|
|
if (typeof localStorage !== "undefined") {
|
|
const saved = localStorage.getItem(AUTHOR_KEY);
|
|
if (saved && !author) author = saved;
|
|
if (saved && !replyAuthor) replyAuthor = saved;
|
|
}
|
|
loadComments();
|
|
});
|
|
|
|
function rememberAuthor(name: string) {
|
|
if (typeof localStorage !== "undefined" && name.trim()) {
|
|
localStorage.setItem(AUTHOR_KEY, name.trim());
|
|
}
|
|
}
|
|
|
|
async function loadComments() {
|
|
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,
|
|
client_age_ms: Date.now() - mountedAt,
|
|
}),
|
|
});
|
|
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);
|
|
rememberAuthor(author);
|
|
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);
|
|
rememberAuthor(replyAuthor);
|
|
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(t(T.comments_confirm_delete))) 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";
|
|
}
|
|
}
|
|
|
|
// Auto-resize a textarea on input.
|
|
function autosize(node: HTMLTextAreaElement) {
|
|
const fit = () => {
|
|
node.style.height = "auto";
|
|
node.style.height = `${node.scrollHeight}px`;
|
|
};
|
|
fit();
|
|
node.addEventListener("input", fit);
|
|
return {
|
|
destroy() {
|
|
node.removeEventListener("input", fit);
|
|
},
|
|
};
|
|
}
|
|
|
|
// Cmd/Ctrl+Enter on a textarea submits the closest form.
|
|
function submitOnCmdEnter(e: KeyboardEvent) {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
const form = (e.currentTarget as HTMLElement).closest("form");
|
|
if (form) {
|
|
e.preventDefault();
|
|
form.requestSubmit();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stable hue from name → consistent avatar colour per author.
|
|
function hueFromName(name: string): number {
|
|
let h = 0;
|
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
|
return h % 360;
|
|
}
|
|
|
|
function initialsFromName(name: string): string {
|
|
const parts = name.trim().split(/\s+/);
|
|
if (parts.length === 0 || !parts[0]) return "?";
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
}
|
|
|
|
function fmtAbs(iso: string): string {
|
|
try {
|
|
return new Date(iso).toLocaleString("de-DE", {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function fmtRel(iso: string): string {
|
|
try {
|
|
const then = new Date(iso).getTime();
|
|
const now = Date.now();
|
|
const diff = Math.round((then - now) / 1000); // seconds, negative = past
|
|
const abs = Math.abs(diff);
|
|
const fmt = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
|
|
if (abs < 60) return fmt.format(diff, "second");
|
|
if (abs < 3600) return fmt.format(Math.round(diff / 60), "minute");
|
|
if (abs < 86_400) return fmt.format(Math.round(diff / 3600), "hour");
|
|
if (abs < 604_800) return fmt.format(Math.round(diff / 86_400), "day");
|
|
return fmtAbs(iso);
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
const topLevel = $derived(comments.filter((c) => !c.parent_id));
|
|
function repliesOf(parentId: string) {
|
|
return comments.filter((c) => c.parent_id === parentId);
|
|
}
|
|
|
|
const totalPending = $derived(comments.filter((c) => c.status === "pending").length);
|
|
</script>
|
|
|
|
{#snippet avatar(name: string, size: "sm" | "md" = "md")}
|
|
{@const hue = hueFromName(name || "?")}
|
|
<span
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white select-none {size === 'sm'
|
|
? 'size-7 text-[11px]'
|
|
: 'size-9 text-xs'}"
|
|
style="background: hsl({hue} 55% 45%);"
|
|
aria-hidden="true"
|
|
>
|
|
{initialsFromName(name || "?")}
|
|
</span>
|
|
{/snippet}
|
|
|
|
{#snippet commentBody(c: PublicComment)}
|
|
{#if editingId === c.id}
|
|
<form
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
saveEdit(c.id);
|
|
}}
|
|
class="mt-2"
|
|
>
|
|
<textarea
|
|
bind:value={editBody}
|
|
use:autosize
|
|
onkeydown={submitOnCmdEnter}
|
|
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
|
|
rows="2"
|
|
maxlength={bodyMax}
|
|
></textarea>
|
|
<div class="mt-1 flex items-center gap-3 text-xs">
|
|
<button
|
|
type="submit"
|
|
class="rounded-xs bg-stein-800 px-3 py-1 font-medium text-white hover:bg-stein-700"
|
|
>{t(T.comments_save)}</button>
|
|
<button
|
|
type="button"
|
|
onclick={cancelEdit}
|
|
class="text-stein-500 hover:text-stein-900"
|
|
>{t(T.comments_cancel)}</button>
|
|
<span class="ml-auto text-stein-400 tabular-nums">{editBody.length} / {bodyMax}</span>
|
|
</div>
|
|
{#if editError}<p class="text-fire-700 text-xs mt-1">{editError}</p>{/if}
|
|
</form>
|
|
{:else}
|
|
<p class="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-stein-800">{c.body}</p>
|
|
{#if c.can_edit}
|
|
<div class="mt-1 flex gap-3 opacity-60 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
type="button"
|
|
onclick={() => startEdit(c)}
|
|
class="text-xs text-stein-600 hover:text-stein-900 inline-flex items-center gap-1"
|
|
>
|
|
<Icon icon="lucide:pencil" class="size-3" />
|
|
{t(T.comments_edit)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => deleteComment(c.id)}
|
|
class="text-xs text-stein-600 hover:text-fire-700 inline-flex items-center gap-1"
|
|
>
|
|
<Icon icon="lucide:trash-2" class="size-3" />
|
|
{t(T.comments_delete)}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet meta(c: PublicComment)}
|
|
<div class="flex flex-wrap items-baseline gap-2 text-sm">
|
|
<strong class="font-medium text-stein-900">{c.author}</strong>
|
|
{#if c.can_edit}
|
|
<span class="text-[10px] uppercase tracking-wide text-stein-400">{t(T.comments_self_marker)}</span>
|
|
{/if}
|
|
<time
|
|
class="text-stein-500 text-xs"
|
|
datetime={c.created_at}
|
|
title={fmtAbs(c.created_at)}
|
|
>{fmtRel(c.created_at)}</time>
|
|
{#if c.updated_at}
|
|
<span class="text-stein-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-erde-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-erde-800">
|
|
<Icon icon="lucide:clock" class="size-3" />
|
|
{t(T.comments_pending_short)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
|
|
<div class="comments {cls}">
|
|
{#if loading && comments.length === 0}
|
|
<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-stein-200 animate-pulse"></div>
|
|
<div class="flex-1 space-y-2">
|
|
<div class="h-3 w-32 bg-stein-200 rounded-xs animate-pulse"></div>
|
|
<div class="h-3 w-full bg-stein-200 rounded-xs animate-pulse"></div>
|
|
<div class="h-3 w-3/4 bg-stein-200 rounded-xs animate-pulse"></div>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else if loadError}
|
|
<div class="flex items-start gap-2 rounded-xs border border-fire-200 bg-fire-50 p-3 text-sm text-fire-800">
|
|
<Icon icon="lucide:circle-alert" class="size-5 shrink-0 mt-0.5" />
|
|
<div class="flex-1">
|
|
<p class="font-medium">{t(T.comments_load_error_title)}</p>
|
|
<p class="mt-1 text-xs text-fire-700">{loadError}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={loadComments}
|
|
class="text-xs text-fire-800 underline hover:no-underline"
|
|
>{t(T.comments_retry)}</button>
|
|
</div>
|
|
{:else}
|
|
{#if topLevel.length === 0}
|
|
<div class="flex flex-col items-center gap-1 py-8 text-center sm:flex-row sm:items-center sm:text-left sm:py-4 sm:gap-1.5">
|
|
<Icon icon="lucide:message-square-text" class="size-5 text-stein-300 shrink-0" />
|
|
<p class="text-sm font-semibold text-stein-300">{t(T.comments_empty)}</p>
|
|
</div>
|
|
{:else}
|
|
<ul class="space-y-5 mb-8 list-none p-0">
|
|
{#each topLevel as c (c.id)}
|
|
{@const replies = repliesOf(c.id)}
|
|
<li
|
|
class="group flex gap-3 list-none! {c.status === 'pending'
|
|
? 'rounded-xs border border-erde-200 bg-erde-50/50 p-3'
|
|
: ''}"
|
|
>
|
|
{@render avatar(c.author)}
|
|
<div class="min-w-0 flex-1">
|
|
{@render meta(c)}
|
|
{@render commentBody(c)}
|
|
|
|
<div class="mt-2">
|
|
<button
|
|
type="button"
|
|
onclick={() => (replyTo = replyTo === c.id ? null : c.id)}
|
|
class="text-xs text-stein-600 hover:text-stein-900 inline-flex items-center gap-1"
|
|
>
|
|
<Icon icon="lucide:reply" class="size-3" />
|
|
{replyTo === c.id
|
|
? 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>
|
|
|
|
{#if replyTo === c.id}
|
|
<form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2">
|
|
<input
|
|
type="text"
|
|
bind:value={replyAuthor}
|
|
placeholder={t(T.comments_name_placeholder)}
|
|
required
|
|
maxlength="80"
|
|
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
|
|
/>
|
|
<textarea
|
|
bind:value={replyBody}
|
|
use:autosize
|
|
onkeydown={submitOnCmdEnter}
|
|
placeholder={t(T.comments_reply_placeholder)}
|
|
required
|
|
rows="2"
|
|
maxlength={bodyMax}
|
|
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
|
|
></textarea>
|
|
{#if replyError}<p class="text-fire-700 text-xs">{replyError}</p>{/if}
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
class="px-3 py-1.5 bg-stein-800 text-white rounded-xs text-sm font-medium hover:bg-stein-700 disabled:opacity-50"
|
|
>{t(T.comments_reply_send)}</button>
|
|
<span class="ml-auto text-xs text-stein-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if replies.length > 0}
|
|
<ul class="mt-4 space-y-4 list-none p-0 border-l border-stein-200 pl-4">
|
|
{#each replies as r (r.id)}
|
|
<li
|
|
class="group flex gap-3 list-none! {r.status === 'pending'
|
|
? 'rounded-xs border border-erde-200 bg-erde-50/50 p-2'
|
|
: ''}"
|
|
>
|
|
{@render avatar(r.author, "sm")}
|
|
<div class="min-w-0 flex-1">
|
|
{@render meta(r)}
|
|
{@render commentBody(r)}
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
<form onsubmit={onSubmit} class="border-t border-stein-200 pt-5 space-y-3">
|
|
<h3 class="font-medium text-sm text-stein-900">{t(T.comments_form_title)}</h3>
|
|
<input
|
|
type="text"
|
|
bind:value={author}
|
|
placeholder={t(T.comments_name_placeholder)}
|
|
required
|
|
maxlength="80"
|
|
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
|
|
/>
|
|
<textarea
|
|
bind:value={body}
|
|
use:autosize
|
|
onkeydown={submitOnCmdEnter}
|
|
placeholder={t(T.comments_body_placeholder)}
|
|
required
|
|
rows="3"
|
|
maxlength={bodyMax}
|
|
class="w-full p-2 border border-stein-300 rounded-xs text-sm focus:outline-none focus:ring-2 focus:ring-stein-400"
|
|
></textarea>
|
|
<input
|
|
type="text"
|
|
bind:value={website}
|
|
tabindex="-1"
|
|
autocomplete="off"
|
|
aria-hidden="true"
|
|
class="absolute left-[-9999px]"
|
|
/>
|
|
{#if submitError}
|
|
<p class="text-fire-700 text-xs">{submitError}</p>
|
|
{/if}
|
|
<div class="flex flex-wrap items-center gap-3 text-xs">
|
|
<button
|
|
type="submit"
|
|
disabled={submitting || !body.trim() || !author.trim()}
|
|
class="px-4 py-2 bg-stein-800 text-white rounded-xs text-sm font-medium hover:bg-stein-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{submitting ? t(T.comments_sending) : t(T.comments_send)}
|
|
</button>
|
|
{#if requireApproval}
|
|
<span class="text-stein-500 inline-flex items-center gap-1">
|
|
<Icon icon="lucide:shield-check" class="size-3.5" />
|
|
{t(T.comments_approval_hint)}
|
|
</span>
|
|
{/if}
|
|
<span class="ml-auto text-stein-400 tabular-nums">{body.length} / {bodyMax}</span>
|
|
</div>
|
|
{#if totalPending > 0}
|
|
<p class="text-xs text-stein-500 italic">
|
|
{totalPending === 1
|
|
? t(T.comments_pending_summary_one)
|
|
: t(T.comments_pending_summary_other, { count: totalPending })}
|
|
</p>
|
|
{/if}
|
|
</form>
|
|
{/if}
|
|
</div>
|