diff --git a/src/lib/components/Comments.svelte b/src/lib/components/Comments.svelte index 3510cdd..fb07816 100644 --- a/src/lib/components/Comments.svelte +++ b/src/lib/components/Comments.svelte @@ -7,10 +7,12 @@ pageId, environment, class: cls = "", + bodyMax = 4096, }: { pageId: string; environment?: string; class?: string; + bodyMax?: number; } = $props(); type Status = "approved" | "pending" | "spam"; @@ -43,11 +45,17 @@ environment ? `?_environment=${encodeURIComponent(environment)}` : "" }`; + const AUTHOR_KEY = "ww_comment_author"; + let comments = $state([]); let requireApproval = $state(false); let loading = $state(true); let loadError = $state(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 @@ -63,12 +71,23 @@ let editBody = $state(""); let editError = $state(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() { - loading = true; loadError = null; try { const r = await fetch(apiUrl(), { credentials: "include" }); @@ -93,6 +112,7 @@ body: text, parent_id: parentId, website, + client_age_ms: Date.now() - mountedAt, }), }); if (!r.ok) { @@ -109,6 +129,7 @@ submitError = null; try { await postComment(author, body, null); + rememberAuthor(author); body = ""; await loadComments(); } catch (e) { @@ -125,6 +146,7 @@ replyError = null; try { await postComment(replyAuthor, replyBody, parentId); + rememberAuthor(replyAuthor); replyBody = ""; replyTo = null; await loadComments(); @@ -178,7 +200,47 @@ } } - function fmtDate(iso: string): string { + // 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", @@ -189,187 +251,259 @@ } } + 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); +{#snippet avatar(name: string, size: "sm" | "md" = "md")} + {@const hue = hueFromName(name || "?")} + +{/snippet} + +{#snippet commentBody(c: PublicComment)} + {#if editingId === c.id} +
{ + e.preventDefault(); + saveEdit(c.id); + }} + class="mt-2" + > + +
+ + + {editBody.length} / {bodyMax} +
+ {#if editError}

{editError}

{/if} +
+ {:else} +

{c.body}

+ {#if c.can_edit} +
+ + +
+ {/if} + {/if} +{/snippet} + +{#snippet meta(c: PublicComment)} +
+ {c.author} + {#if c.can_edit} + du + {/if} + + {#if c.updated_at} + (bearbeitet) + {/if} + {#if c.status === "pending"} + + + wartet + + {/if} +
+{/snippet} +
{#if loading && comments.length === 0} -

Lade Kommentare …

- {:else if loadError} -

Fehler: {loadError}

- {:else} - {#if topLevel.length === 0} -

Noch keine Kommentare. Sei der erste.

- {/if} - -
    - {#each topLevel as c (c.id)} -
  • -
    - {c.author} - - {#if c.updated_at} - (bearbeitet) - {/if} - {#if c.status === "pending"} - (wartet auf Freigabe) - {/if} +
      + {#each Array(2) as _, i (i)} +
    • +
      +
      +
      +
      +
      - - {#if editingId === c.id} - - {#if editError}

      {editError}

      {/if} -
      - - -
      - {:else} -

      {c.body}

      - {#if c.can_edit} -
      - - -
      - {/if} - {/if} - -
      - -
      - - {#if replyTo === c.id} -
      onReply(e, c.id)} - class="mt-2 ml-4 space-y-2" - > - - - {#if replyError}

      {replyError}

      {/if} - -
      - {/if} - - {#if repliesOf(c.id).length > 0} -
        - {#each repliesOf(c.id) as r (r.id)} -
      • -
        - {r.author} - - {#if r.updated_at} - (bearbeitet) - {/if} - {#if r.status === "pending"} - (wartet auf Freigabe) - {/if} -
        - {#if editingId === r.id} - - {#if editError}

        {editError}

        {/if} -
        - - -
        - {:else} -

        {r.body}

        - {#if r.can_edit} -
        - - -
        - {/if} - {/if} -
      • - {/each} -
      - {/if}
    • {/each}
    + {:else if loadError} +
    + +
    +

    Kommentare konnten nicht geladen werden.

    +

    {loadError}

    +
    + +
    + {:else} + {#if topLevel.length === 0} +
    + +

    Noch keine Kommentare. Sei der erste.

    +
    + {:else} +
      + {#each topLevel as c (c.id)} + {@const replies = repliesOf(c.id)} +
    • + {@render avatar(c.author)} +
      + {@render meta(c)} + {@render commentBody(c)} -
      -

      Kommentar schreiben

      +
      + +
      + + {#if replyTo === c.id} + onReply(e, c.id)} class="mt-3 space-y-2"> + + + {#if replyError}

      {replyError}

      {/if} +
      + + {replyBody.length} / {bodyMax} +
      +
      + {/if} + + {#if replies.length > 0} +
        + {#each replies as r (r.id)} +
      • + {@render avatar(r.author, "sm")} +
        + {@render meta(r)} + {@render commentBody(r)} +
        +
      • + {/each} +
      + {/if} +
      +
    • + {/each} +
    + {/if} + +
    +

    Kommentar schreiben

    - {#if submitError}

    {submitError}

    {/if} - {#if requireApproval} -

    Kommentare werden vor Veröffentlichung geprüft.

    + {#if submitError} +

    {submitError}

    + {/if} +
    + + {#if requireApproval} + + + Wird vor Veröffentlichung geprüft. + + {/if} + {body.length} / {bodyMax} +
    + {#if totalPending > 0} +

    + {totalPending} {totalPending === 1 ? 'Kommentar wartet' : 'Kommentare warten'} noch auf Freigabe (nur für dich sichtbar). +

    {/if} -
    {/if}