Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Meier 8f45094f64 style(comments): move count badge under image, drop underline
Deploy / verify (push) Successful in 43s
Deploy / deploy (push) Successful in 1m3s
Repositions the CommentCountBadge out of the date row into its own
container below the post image. Removes hover colour-change + the
prose underline (`no-underline\!`) so the badge sits flat with the
post chrome.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:21:05 +02:00
Peter Meier 40b2d406ae fix(rusty-image): derive arValue from arString for layout match
`arValue` (height computation) and `arString` (URL `ar=` param) used
to read from different sources, so the URL could request a different
aspect than the `<img>` element reserved — caused a visible flicker
when the transformed image loaded. Both now derive from `arString`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:20:59 +02:00
Peter Meier 9d1a7ab46d feat(comments): polish UI + send client_age_ms
- Avatars with hash-derived hue, initials per author
- Relative dates (Intl.RelativeTimeFormat), absolute as title
- localStorage remembers author name for next visit
- Auto-resize textarea, char counter, Cmd+Enter submit
- Skeleton loading state, icon empty state, retry button on error
- Pending comments get amber card + status pill (visible only to author)
- Reply count in toggle ("3 Antworten")
- Edit/delete buttons fade in on hover (group-hover)
- DRY: meta + body rendering as snippets, used by top-level + replies
- Sends `client_age_ms` for backend's anti-instant-bot check

No content filtering — only timing / structure / re-use checks live in
the backend now (sha256 dedupe, session cooldown, banned IPs, author
char sanity).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:18:23 +02:00
4 changed files with 383 additions and 205 deletions
+1 -1
View File
@@ -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
View File
@@ -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>
+8 -4
View File
@@ -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 {
+58 -33
View File
@@ -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> </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>
<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>