i18n(comments): all UI strings through translations

Pulls every German string out of Comments.svelte, PostCard.svelte and
CommentCountBadge.svelte and routes it through `useTranslate()` +
the `T.*` key registry. New keys (`comments_*`, `post_overview` count
aria, `comments_jump_to`) are registered in `lib/translations.ts`.

Default DE values land in
`cms_content/windwiderstand/de/translation_bundle/app.json5` (committed
separately) so the CMS-loaded bundle keeps the same look without each
component falling back to its raw key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-04-28 14:34:48 +02:00
parent 8f791ebc54
commit e4804ce673
4 changed files with 70 additions and 28 deletions
+4 -1
View File
@@ -2,6 +2,9 @@
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
const t = useTranslate();
let {
pageId,
@@ -54,7 +57,7 @@
<a
href="#{targetId}"
onclick={onClick}
aria-label="Zu Kommentaren springen"
aria-label={t(T.comments_jump_to)}
class="no-underline! inline-flex items-center gap-1 text-xs font-medium transition-colors {cls}"
>
<Icon icon="mdi:comment-outline" class="size-4" />
+32 -25
View File
@@ -2,6 +2,7 @@
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
let {
pageId,
@@ -15,6 +16,8 @@
bodyMax?: number;
} = $props();
const t = useTranslate();
type Status = "approved" | "pending" | "spam";
type PublicComment = {
@@ -187,7 +190,7 @@
}
async function deleteComment(id: string) {
if (!confirm("Kommentar löschen?")) return;
if (!confirm(t(T.comments_confirm_delete))) return;
try {
const r = await fetch(`${apiUrl()}`.replace(`/${pageId}`, `/${pageId}/${id}`), {
method: "DELETE",
@@ -310,12 +313,12 @@
<button
type="submit"
class="rounded bg-zinc-800 px-3 py-1 font-medium text-white hover:bg-zinc-700"
>Speichern</button>
>{t(T.comments_save)}</button>
<button
type="button"
onclick={cancelEdit}
class="text-zinc-500 hover:text-zinc-900"
>Abbrechen</button>
>{t(T.comments_cancel)}</button>
<span class="ml-auto text-zinc-400 tabular-nums">{editBody.length} / {bodyMax}</span>
</div>
{#if editError}<p class="text-red-700 text-xs mt-1">{editError}</p>{/if}
@@ -330,7 +333,7 @@
class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
>
<Icon icon="mdi:pencil-outline" class="size-3" />
Bearbeiten
{t(T.comments_edit)}
</button>
<button
type="button"
@@ -338,7 +341,7 @@
class="text-xs text-zinc-600 hover:text-red-700 inline-flex items-center gap-1"
>
<Icon icon="mdi:trash-can-outline" class="size-3" />
Löschen
{t(T.comments_delete)}
</button>
</div>
{/if}
@@ -349,7 +352,7 @@
<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>
<span class="text-[10px] uppercase tracking-wide text-zinc-400">{t(T.comments_self_marker)}</span>
{/if}
<time
class="text-zinc-500 text-xs"
@@ -357,12 +360,12 @@
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>
<span class="text-zinc-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-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
{t(T.comments_pending_short)}
</span>
{/if}
</div>
@@ -370,7 +373,7 @@
<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">
<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-zinc-200 animate-pulse"></div>
@@ -386,20 +389,20 @@
<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="font-medium">{t(T.comments_load_error_title)}</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>
>{t(T.comments_retry)}</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>
<p class="text-sm text-zinc-500">{t(T.comments_empty)}</p>
</div>
{:else}
<ul class="space-y-5 mb-8 list-none p-0">
@@ -423,10 +426,12 @@
>
<Icon icon="mdi:reply" class="size-3" />
{replyTo === c.id
? "Abbrechen"
: replies.length > 0
? `${replies.length} ${replies.length === 1 ? 'Antwort' : 'Antworten'}`
: "Antworten"}
? 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>
@@ -435,7 +440,7 @@
<input
type="text"
bind:value={replyAuthor}
placeholder="Dein Name"
placeholder={t(T.comments_name_placeholder)}
required
maxlength="80"
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
@@ -444,7 +449,7 @@
bind:value={replyBody}
use:autosize
onkeydown={submitOnCmdEnter}
placeholder="Deine Antwort"
placeholder={t(T.comments_reply_placeholder)}
required
rows="2"
maxlength={bodyMax}
@@ -456,7 +461,7 @@
type="submit"
disabled={submitting}
class="px-3 py-1.5 bg-zinc-800 text-white rounded text-sm font-medium hover:bg-zinc-700 disabled:opacity-50"
>Antwort senden</button>
>{t(T.comments_reply_send)}</button>
<span class="ml-auto text-xs text-zinc-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
</div>
</form>
@@ -486,11 +491,11 @@
{/if}
<form onsubmit={onSubmit} class="border-t border-zinc-200 pt-5 space-y-3">
<h3 class="font-medium text-sm text-zinc-900">Kommentar schreiben</h3>
<h3 class="font-medium text-sm text-zinc-900">{t(T.comments_form_title)}</h3>
<input
type="text"
bind:value={author}
placeholder="Dein Name"
placeholder={t(T.comments_name_placeholder)}
required
maxlength="80"
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400"
@@ -499,7 +504,7 @@
bind:value={body}
use:autosize
onkeydown={submitOnCmdEnter}
placeholder="Schreibe etwas Konstruktives. Cmd/Ctrl+Enter sendet."
placeholder={t(T.comments_body_placeholder)}
required
rows="3"
maxlength={bodyMax}
@@ -522,19 +527,21 @@
disabled={submitting || !body.trim() || !author.trim()}
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"
>
{submitting ? "Sende …" : "Senden"}
{submitting ? t(T.comments_sending) : t(T.comments_send)}
</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.
{t(T.comments_approval_hint)}
</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).
{totalPending === 1
? t(T.comments_pending_summary_one)
: t(T.comments_pending_summary_other, { count: totalPending })}
</p>
{/if}
</form>
+5 -2
View File
@@ -6,6 +6,9 @@
import EventBadges from "./EventBadges.svelte";
import RustyImage from "./RustyImage.svelte";
import Card from "./Card.svelte";
import { useTranslate, T } from "$lib/translations";
const t = useTranslate();
type PostWithImage = PostEntry & {
_resolvedImageUrl?: string;
@@ -98,8 +101,8 @@
<div class="absolute top-2 left-2">
<span
class="inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 text-[.65rem] font-semibold tabular-nums text-zinc-700 shadow-sm"
aria-label="{commentCount} Kommentare"
title="{commentCount} Kommentare"
aria-label={t(T.comments_count_aria, { count: commentCount })}
title={t(T.comments_count_aria, { count: commentCount })}
>
<Icon icon="mdi:comment-outline" class="size-3" />
{commentCount}
+29
View File
@@ -133,6 +133,35 @@ const TRANSLATION_KEYS = [
"post_map_activate",
"post_map_open_osm",
"post_comments",
// Comment plugin (Comments.svelte / PostCard count pill / CommentCountBadge).
"comments_loading",
"comments_load_error_title",
"comments_retry",
"comments_empty",
"comments_edit",
"comments_delete",
"comments_save",
"comments_cancel",
"comments_confirm_delete",
"comments_reply",
"comments_reply_count_one",
"comments_reply_count_other",
"comments_reply_send",
"comments_send",
"comments_sending",
"comments_form_title",
"comments_name_placeholder",
"comments_body_placeholder",
"comments_reply_placeholder",
"comments_approval_hint",
"comments_edited",
"comments_pending_short",
"comments_pending_full",
"comments_self_marker",
"comments_pending_summary_one",
"comments_pending_summary_other",
"comments_count_aria",
"comments_jump_to",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];