Compare commits
3 Commits
817f658977
...
8f45094f64
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f45094f64 | |||
| 40b2d406ae | |||
| 9d1a7ab46d |
@@ -55,7 +55,7 @@
|
|||||||
href="#{targetId}"
|
href="#{targetId}"
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
aria-label="Zu Kommentaren springen"
|
aria-label="Zu Kommentaren springen"
|
||||||
class="inline-flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 transition-colors {cls}"
|
class="no-underline! inline-flex items-center gap-1 text-xs font-medium transition-colors {cls}"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:comment-outline" class="size-4" />
|
<Icon icon="mdi:comment-outline" class="size-4" />
|
||||||
<span class="tabular-nums">{count ?? "…"}</span>
|
<span class="tabular-nums">{count ?? "…"}</span>
|
||||||
|
|||||||
+261
-112
@@ -7,10 +7,12 @@
|
|||||||
pageId,
|
pageId,
|
||||||
environment,
|
environment,
|
||||||
class: cls = "",
|
class: cls = "",
|
||||||
|
bodyMax = 4096,
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
bodyMax?: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
type Status = "approved" | "pending" | "spam";
|
type Status = "approved" | "pending" | "spam";
|
||||||
@@ -43,11 +45,17 @@
|
|||||||
environment ? `?_environment=${encodeURIComponent(environment)}` : ""
|
environment ? `?_environment=${encodeURIComponent(environment)}` : ""
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
const AUTHOR_KEY = "ww_comment_author";
|
||||||
|
|
||||||
let comments = $state<PublicComment[]>([]);
|
let comments = $state<PublicComment[]>([]);
|
||||||
let requireApproval = $state(false);
|
let requireApproval = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadError = $state<string | null>(null);
|
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 author = $state("");
|
||||||
let body = $state("");
|
let body = $state("");
|
||||||
let website = $state(""); // honeypot
|
let website = $state(""); // honeypot
|
||||||
@@ -63,12 +71,23 @@
|
|||||||
let editBody = $state("");
|
let editBody = $state("");
|
||||||
let editError = $state<string | null>(null);
|
let editError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Restore name from localStorage on first mount.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
const saved = localStorage.getItem(AUTHOR_KEY);
|
||||||
|
if (saved && !author) author = saved;
|
||||||
|
if (saved && !replyAuthor) replyAuthor = saved;
|
||||||
|
}
|
||||||
loadComments();
|
loadComments();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function rememberAuthor(name: string) {
|
||||||
|
if (typeof localStorage !== "undefined" && name.trim()) {
|
||||||
|
localStorage.setItem(AUTHOR_KEY, name.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadComments() {
|
async function loadComments() {
|
||||||
loading = true;
|
|
||||||
loadError = null;
|
loadError = null;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(apiUrl(), { credentials: "include" });
|
const r = await fetch(apiUrl(), { credentials: "include" });
|
||||||
@@ -93,6 +112,7 @@
|
|||||||
body: text,
|
body: text,
|
||||||
parent_id: parentId,
|
parent_id: parentId,
|
||||||
website,
|
website,
|
||||||
|
client_age_ms: Date.now() - mountedAt,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -109,6 +129,7 @@
|
|||||||
submitError = null;
|
submitError = null;
|
||||||
try {
|
try {
|
||||||
await postComment(author, body, null);
|
await postComment(author, body, null);
|
||||||
|
rememberAuthor(author);
|
||||||
body = "";
|
body = "";
|
||||||
await loadComments();
|
await loadComments();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -125,6 +146,7 @@
|
|||||||
replyError = null;
|
replyError = null;
|
||||||
try {
|
try {
|
||||||
await postComment(replyAuthor, replyBody, parentId);
|
await postComment(replyAuthor, replyBody, parentId);
|
||||||
|
rememberAuthor(replyAuthor);
|
||||||
replyBody = "";
|
replyBody = "";
|
||||||
replyTo = null;
|
replyTo = null;
|
||||||
await loadComments();
|
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 {
|
try {
|
||||||
return new Date(iso).toLocaleString("de-DE", {
|
return new Date(iso).toLocaleString("de-DE", {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
@@ -189,72 +251,169 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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));
|
const topLevel = $derived(comments.filter((c) => !c.parent_id));
|
||||||
function repliesOf(parentId: string) {
|
function repliesOf(parentId: string) {
|
||||||
return comments.filter((c) => c.parent_id === parentId);
|
return comments.filter((c) => c.parent_id === parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalPending = $derived(comments.filter((c) => c.status === "pending").length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="comments {cls}">
|
{#snippet avatar(name: string, size: "sm" | "md" = "md")}
|
||||||
{#if loading && comments.length === 0}
|
{@const hue = hueFromName(name || "?")}
|
||||||
<p class="text-sm text-zinc-500">Lade Kommentare …</p>
|
<span
|
||||||
{:else if loadError}
|
class="inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white select-none {size === 'sm'
|
||||||
<p class="text-sm text-red-700">Fehler: {loadError}</p>
|
? 'size-7 text-[11px]'
|
||||||
{:else}
|
: 'size-9 text-xs'}"
|
||||||
{#if topLevel.length === 0}
|
style="background: hsl({hue} 55% 45%);"
|
||||||
<p class="text-sm text-zinc-500 mb-4">Noch keine Kommentare. Sei der erste.</p>
|
aria-hidden="true"
|
||||||
{/if}
|
>
|
||||||
|
{initialsFromName(name || "?")}
|
||||||
<ul class="space-y-4 mb-8 list-none p-0">
|
</span>
|
||||||
{#each topLevel as c (c.id)}
|
{/snippet}
|
||||||
<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>
|
|
||||||
|
|
||||||
|
{#snippet commentBody(c: PublicComment)}
|
||||||
{#if editingId === c.id}
|
{#if editingId === c.id}
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
saveEdit(c.id);
|
||||||
|
}}
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={editBody}
|
bind:value={editBody}
|
||||||
class="w-full mt-2 p-2 border border-zinc-300 rounded text-sm"
|
use:autosize
|
||||||
rows="3"
|
onkeydown={submitOnCmdEnter}
|
||||||
|
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||||
|
rows="2"
|
||||||
|
maxlength={bodyMax}
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if editError}<p class="text-red-700 text-xs mt-1">{editError}</p>{/if}
|
<div class="mt-1 flex items-center gap-3 text-xs">
|
||||||
<div class="mt-1 flex gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
onclick={() => saveEdit(c.id)}
|
class="rounded bg-zinc-800 px-3 py-1 font-medium text-white hover:bg-zinc-700"
|
||||||
class="text-sm text-blue-700 hover:underline"
|
|
||||||
>Speichern</button>
|
>Speichern</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={cancelEdit}
|
onclick={cancelEdit}
|
||||||
class="text-sm text-zinc-500 hover:underline"
|
class="text-zinc-500 hover:text-zinc-900"
|
||||||
>Abbrechen</button>
|
>Abbrechen</button>
|
||||||
|
<span class="ml-auto text-zinc-400 tabular-nums">{editBody.length} / {bodyMax}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if editError}<p class="text-red-700 text-xs mt-1">{editError}</p>{/if}
|
||||||
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mt-1 whitespace-pre-wrap text-sm">{c.body}</p>
|
<p class="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800">{c.body}</p>
|
||||||
{#if c.can_edit}
|
{#if c.can_edit}
|
||||||
<div class="mt-1 flex gap-3">
|
<div class="mt-1 flex gap-3 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => startEdit(c)}
|
onclick={() => startEdit(c)}
|
||||||
class="text-xs text-zinc-600 hover:text-zinc-900"
|
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
|
||||||
>Bearbeiten</button>
|
>
|
||||||
|
<Icon icon="mdi:pencil-outline" class="size-3" />
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => deleteComment(c.id)}
|
onclick={() => deleteComment(c.id)}
|
||||||
class="text-xs text-zinc-600 hover:text-red-700"
|
class="text-xs text-zinc-600 hover:text-red-700 inline-flex items-center gap-1"
|
||||||
>Löschen</button>
|
>
|
||||||
|
<Icon icon="mdi:trash-can-outline" class="size-3" />
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet meta(c: PublicComment)}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
<time
|
||||||
|
class="text-zinc-500 text-xs"
|
||||||
|
datetime={c.created_at}
|
||||||
|
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>
|
||||||
|
{/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
|
||||||
|
</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="Lade Kommentare">
|
||||||
|
{#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>
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="h-3 w-32 bg-zinc-200 rounded animate-pulse"></div>
|
||||||
|
<div class="h-3 w-full bg-zinc-200 rounded animate-pulse"></div>
|
||||||
|
<div class="h-3 w-3/4 bg-zinc-200 rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if loadError}
|
||||||
|
<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="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>
|
||||||
|
</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>
|
||||||
|
{: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-md border border-amber-200 bg-amber-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">
|
<div class="mt-2">
|
||||||
<button
|
<button
|
||||||
@@ -263,113 +422,88 @@
|
|||||||
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
|
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:reply" class="size-3" />
|
<Icon icon="mdi:reply" class="size-3" />
|
||||||
Antworten
|
{replyTo === c.id
|
||||||
|
? "Abbrechen"
|
||||||
|
: replies.length > 0
|
||||||
|
? `${replies.length} ${replies.length === 1 ? 'Antwort' : 'Antworten'}`
|
||||||
|
: "Antworten"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if replyTo === c.id}
|
{#if replyTo === c.id}
|
||||||
<form
|
<form onsubmit={(e) => onReply(e, c.id)} class="mt-3 space-y-2">
|
||||||
onsubmit={(e) => onReply(e, c.id)}
|
|
||||||
class="mt-2 ml-4 space-y-2"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={replyAuthor}
|
bind:value={replyAuthor}
|
||||||
placeholder="Name"
|
placeholder="Dein Name"
|
||||||
required
|
required
|
||||||
maxlength="80"
|
maxlength="80"
|
||||||
class="w-full p-2 border border-zinc-300 rounded text-sm"
|
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={replyBody}
|
bind:value={replyBody}
|
||||||
placeholder="Antwort"
|
use:autosize
|
||||||
|
onkeydown={submitOnCmdEnter}
|
||||||
|
placeholder="Deine Antwort"
|
||||||
required
|
required
|
||||||
rows="2"
|
rows="2"
|
||||||
class="w-full p-2 border border-zinc-300 rounded text-sm"
|
maxlength={bodyMax}
|
||||||
|
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if replyError}<p class="text-red-700 text-xs">{replyError}</p>{/if}
|
{#if replyError}<p class="text-red-700 text-xs">{replyError}</p>{/if}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
class="px-3 py-1 bg-zinc-800 text-white rounded text-sm hover:bg-zinc-700 disabled:opacity-50"
|
class="px-3 py-1.5 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50"
|
||||||
>Senden</button>
|
>Antwort senden</button>
|
||||||
|
<span class="ml-auto text-xs text-zinc-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if repliesOf(c.id).length > 0}
|
{#if replies.length > 0}
|
||||||
<ul class="mt-3 ml-4 space-y-3 list-none p-0">
|
<ul class="mt-4 space-y-4 list-none p-0 border-l border-zinc-200 pl-4">
|
||||||
{#each repliesOf(c.id) as r (r.id)}
|
{#each replies as r (r.id)}
|
||||||
<li class="border-l-2 border-zinc-100 pl-3">
|
<li
|
||||||
<div class="flex items-baseline gap-2 text-sm">
|
class="group flex gap-3 list-none! {r.status === 'pending'
|
||||||
<strong class="font-medium">{r.author}</strong>
|
? 'rounded-md border border-amber-200 bg-amber-50/50 p-2'
|
||||||
<time class="text-zinc-500">{fmtDate(r.created_at)}</time>
|
: ''}"
|
||||||
{#if r.updated_at}
|
>
|
||||||
<span class="text-zinc-400 text-xs">(bearbeitet)</span>
|
{@render avatar(r.author, "sm")}
|
||||||
{/if}
|
<div class="min-w-0 flex-1">
|
||||||
{#if r.status === "pending"}
|
{@render meta(r)}
|
||||||
<span class="text-amber-700 text-xs">(wartet auf Freigabe)</span>
|
{@render commentBody(r)}
|
||||||
{/if}
|
|
||||||
</div>
|
</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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={onSubmit} class="space-y-2 border-t border-zinc-200 pt-4">
|
<form onsubmit={onSubmit} class="border-t border-zinc-200 pt-5 space-y-3">
|
||||||
<h3 class="font-medium text-sm">Kommentar schreiben</h3>
|
<h3 class="font-medium text-sm text-zinc-900">Kommentar schreiben</h3>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={author}
|
bind:value={author}
|
||||||
placeholder="Name"
|
placeholder="Dein Name"
|
||||||
required
|
required
|
||||||
maxlength="80"
|
maxlength="80"
|
||||||
class="w-full p-2 border border-zinc-300 rounded text-sm"
|
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={body}
|
bind:value={body}
|
||||||
placeholder="Dein Kommentar"
|
use:autosize
|
||||||
|
onkeydown={submitOnCmdEnter}
|
||||||
|
placeholder="Schreibe etwas Konstruktives. Cmd/Ctrl+Enter sendet."
|
||||||
required
|
required
|
||||||
rows="4"
|
rows="3"
|
||||||
class="w-full p-2 border border-zinc-300 rounded text-sm"
|
maxlength={bodyMax}
|
||||||
|
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
|
||||||
></textarea>
|
></textarea>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -379,15 +513,30 @@
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="absolute left-[-9999px]"
|
class="absolute left-[-9999px]"
|
||||||
/>
|
/>
|
||||||
{#if submitError}<p class="text-red-700 text-xs">{submitError}</p>{/if}
|
{#if submitError}
|
||||||
{#if requireApproval}
|
<p class="text-red-700 text-xs">{submitError}</p>
|
||||||
<p class="text-xs text-zinc-500">Kommentare werden vor Veröffentlichung geprüft.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting || !body.trim() || !author.trim()}
|
||||||
class="px-4 py-2 bg-zinc-800 text-white rounded text-sm hover:bg-zinc-700 disabled:opacity-50"
|
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"
|
||||||
>Senden</button>
|
>
|
||||||
|
{submitting ? "Sende …" : "Senden"}
|
||||||
|
</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.
|
||||||
|
</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).
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,14 +71,18 @@
|
|||||||
return w / h;
|
return w / h;
|
||||||
}
|
}
|
||||||
|
|
||||||
const arValue = $derived(parseAr(aspect));
|
// Derive aspect from either the explicit `aspect` prop or the width/height pair.
|
||||||
const computedHeight = $derived(
|
// Both `arString` (URL `ar=` param) and `arValue` (height-derivation) must read
|
||||||
height ?? (arValue ? Math.round(width / arValue) : undefined),
|
// from the SAME source — otherwise the URL requests a different shape than the
|
||||||
);
|
// `<img>` reserves and the image flickers when it loads.
|
||||||
const arString = $derived(
|
const arString = $derived(
|
||||||
aspect ??
|
aspect ??
|
||||||
(height && width ? `${width}/${height}` : undefined),
|
(height && width ? `${width}/${height}` : undefined),
|
||||||
);
|
);
|
||||||
|
const arValue = $derived(parseAr(arString));
|
||||||
|
const computedHeight = $derived(
|
||||||
|
height ?? (arValue ? Math.round(width / arValue) : undefined),
|
||||||
|
);
|
||||||
const sizesAttr = $derived(sizes ?? `${width}px`);
|
const sizesAttr = $derived(sizes ?? `${width}px`);
|
||||||
|
|
||||||
function baseParams(format: "avif" | "webp" | "jpeg", w: number): RustyImageTransformParams {
|
function baseParams(format: "avif" | "webp" | "jpeg", w: number): RustyImageTransformParams {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ContentRows from '$lib/components/ContentRows.svelte';
|
import ContentRows from "$lib/components/ContentRows.svelte";
|
||||||
import Tag from '$lib/components/Tag.svelte';
|
import Tag from "$lib/components/Tag.svelte";
|
||||||
import Tags from '$lib/components/Tags.svelte';
|
import Tags from "$lib/components/Tags.svelte";
|
||||||
import EventBadges from '$lib/components/EventBadges.svelte';
|
import EventBadges from "$lib/components/EventBadges.svelte";
|
||||||
import EventMap from '$lib/components/EventMap.svelte';
|
import EventMap from "$lib/components/EventMap.svelte";
|
||||||
import Accordion from '$lib/components/Accordion.svelte';
|
import Accordion from "$lib/components/Accordion.svelte";
|
||||||
import CommentCountBadge from '$lib/components/CommentCountBadge.svelte';
|
import CommentCountBadge from "$lib/components/CommentCountBadge.svelte";
|
||||||
import Comments from '$lib/components/Comments.svelte';
|
import Comments from "$lib/components/Comments.svelte";
|
||||||
import RustyImage from '$lib/components/RustyImage.svelte';
|
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||||
import { t, T } from '$lib/translations';
|
import { t, T } from "$lib/translations";
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -26,7 +26,9 @@
|
|||||||
|
|
||||||
<div class="md:flex gap-2 items-end">
|
<div class="md:flex gap-2 items-end">
|
||||||
{#if data.rawPostImageUrl}
|
{#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">
|
<div
|
||||||
|
class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg aspect-square"
|
||||||
|
>
|
||||||
<RustyImage
|
<RustyImage
|
||||||
src={data.rawPostImageUrl}
|
src={data.rawPostImageUrl}
|
||||||
focal={data.postImageFocal}
|
focal={data.postImageFocal}
|
||||||
@@ -41,7 +43,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if data.postImageUrl}
|
{: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">
|
<div
|
||||||
|
class="overflow-hidden inline-block my-4 border border-white self-start ring-1 ring-black/50 shadow-lg aspect-square"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={data.postImageUrl}
|
src={data.postImageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -59,29 +63,40 @@
|
|||||||
{#if data.postDate}
|
{#if data.postDate}
|
||||||
<Tag label={data.postDate} variant="date" />
|
<Tag label={data.postDate} variant="date" />
|
||||||
{/if}
|
{/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"
|
||||||
|
>
|
||||||
|
<Tags
|
||||||
|
tags={data.post.postTag ?? []}
|
||||||
|
variant="green"
|
||||||
|
tagBase="/posts/tag"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
{#if data.commentPageId}
|
{#if data.commentPageId}
|
||||||
<CommentCountBadge pageId={data.commentPageId} targetId="comments-section" />
|
<CommentCountBadge
|
||||||
|
pageId={data.commentPageId}
|
||||||
|
targetId="comments-section"
|
||||||
|
/>
|
||||||
{/if}
|
{/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">
|
|
||||||
<Tags tags={data.post.postTag ?? []} variant="green" tagBase="/posts/tag" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
class="mt-8 h-entry"
|
class="mt-8 h-entry"
|
||||||
itemscope
|
itemscope
|
||||||
itemtype={data.isEvent ? 'https://schema.org/Event' : 'https://schema.org/BlogPosting'}
|
itemtype={data.isEvent
|
||||||
|
? "https://schema.org/Event"
|
||||||
|
: "https://schema.org/BlogPosting"}
|
||||||
>
|
>
|
||||||
<a class="u-url" href="#" aria-hidden="true" hidden></a>
|
<a class="u-url" href="#" aria-hidden="true" hidden></a>
|
||||||
{#if data.post.created}
|
{#if data.post.created}
|
||||||
<time
|
<time class="dt-published" datetime={String(data.post.created)} hidden
|
||||||
class="dt-published"
|
>{data.post.created}</time
|
||||||
datetime={String(data.post.created)}
|
>
|
||||||
hidden
|
|
||||||
>{data.post.created}</time>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.contentHeadlineHtml}
|
{#if data.contentHeadlineHtml}
|
||||||
<div class="content markdown max-w-none prose prose-zinc p-name">
|
<div class="content markdown max-w-none prose prose-zinc p-name">
|
||||||
@@ -105,7 +120,9 @@
|
|||||||
<EventBadges
|
<EventBadges
|
||||||
eventDate={data.eventDateRaw}
|
eventDate={data.eventDateRaw}
|
||||||
locationText={data.eventLocationText}
|
locationText={data.eventLocationText}
|
||||||
locationHref={(data.mapEmbedUrl || data.mapLinkUrl) ? '#map-section' : null}
|
locationHref={data.mapEmbedUrl || data.mapLinkUrl
|
||||||
|
? "#map-section"
|
||||||
|
: null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -119,11 +136,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if (data.post.postTag ?? []).length > 0}
|
{#if (data.post.postTag ?? []).length > 0}
|
||||||
<div hidden>
|
<div hidden>
|
||||||
{#each (data.post.postTag ?? []) as tag}
|
{#each data.post.postTag ?? [] as tag}
|
||||||
{@const tagName =
|
{@const tagName =
|
||||||
tag && typeof tag === 'object'
|
tag && typeof tag === "object"
|
||||||
? ((tag as { name?: string }).name ?? '')
|
? ((tag as { name?: string }).name ?? "")
|
||||||
: ''}
|
: ""}
|
||||||
{#if tagName}
|
{#if tagName}
|
||||||
<span class="p-category">{tagName}</span>
|
<span class="p-category">{tagName}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -131,7 +148,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="p-author h-card" hidden>
|
<div class="p-author h-card" hidden>
|
||||||
<span class="p-name">{(data.post as { author?: string }).author ?? 'Bürgerinitiative Vachdorf'}</span>
|
<span class="p-name"
|
||||||
|
>{(data.post as { author?: string }).author ??
|
||||||
|
"Bürgerinitiative Vachdorf"}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -146,7 +166,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.commentPageId}
|
{#if data.commentPageId}
|
||||||
<Accordion id="comments-section" label={t(data.translations, T.post_comments)} lazy class="mt-6">
|
<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">
|
<div class="p-8 border border-zinc-200 rounded-lg">
|
||||||
<Comments pageId={data.commentPageId} />
|
<Comments pageId={data.commentPageId} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user