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 Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
const t = useTranslate();
let { let {
pageId, pageId,
@@ -54,7 +57,7 @@
<a <a
href="#{targetId}" href="#{targetId}"
onclick={onClick} 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}" 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" />
+32 -25
View File
@@ -2,6 +2,7 @@
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import "$lib/iconify-offline"; import "$lib/iconify-offline";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
let { let {
pageId, pageId,
@@ -15,6 +16,8 @@
bodyMax?: number; bodyMax?: number;
} = $props(); } = $props();
const t = useTranslate();
type Status = "approved" | "pending" | "spam"; type Status = "approved" | "pending" | "spam";
type PublicComment = { type PublicComment = {
@@ -187,7 +190,7 @@
} }
async function deleteComment(id: string) { async function deleteComment(id: string) {
if (!confirm("Kommentar löschen?")) return; if (!confirm(t(T.comments_confirm_delete))) return;
try { try {
const r = await fetch(`${apiUrl()}`.replace(`/${pageId}`, `/${pageId}/${id}`), { const r = await fetch(`${apiUrl()}`.replace(`/${pageId}`, `/${pageId}/${id}`), {
method: "DELETE", method: "DELETE",
@@ -310,12 +313,12 @@
<button <button
type="submit" type="submit"
class="rounded bg-zinc-800 px-3 py-1 font-medium text-white hover:bg-zinc-700" 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 <button
type="button" type="button"
onclick={cancelEdit} onclick={cancelEdit}
class="text-zinc-500 hover:text-zinc-900" 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> <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} {#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" class="text-xs text-zinc-600 hover:text-zinc-900 inline-flex items-center gap-1"
> >
<Icon icon="mdi:pencil-outline" class="size-3" /> <Icon icon="mdi:pencil-outline" class="size-3" />
Bearbeiten {t(T.comments_edit)}
</button> </button>
<button <button
type="button" type="button"
@@ -338,7 +341,7 @@
class="text-xs text-zinc-600 hover:text-red-700 inline-flex items-center gap-1" 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" /> <Icon icon="mdi:trash-can-outline" class="size-3" />
Löschen {t(T.comments_delete)}
</button> </button>
</div> </div>
{/if} {/if}
@@ -349,7 +352,7 @@
<div class="flex flex-wrap items-baseline gap-2 text-sm"> <div class="flex flex-wrap items-baseline gap-2 text-sm">
<strong class="font-medium text-zinc-900">{c.author}</strong> <strong class="font-medium text-zinc-900">{c.author}</strong>
{#if c.can_edit} {#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} {/if}
<time <time
class="text-zinc-500 text-xs" class="text-zinc-500 text-xs"
@@ -357,12 +360,12 @@
title={fmtAbs(c.created_at)} title={fmtAbs(c.created_at)}
>{fmtRel(c.created_at)}</time> >{fmtRel(c.created_at)}</time>
{#if c.updated_at} {#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}
{#if c.status === "pending"} {#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"> <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" /> <Icon icon="mdi:clock-outline" class="size-3" />
wartet {t(T.comments_pending_short)}
</span> </span>
{/if} {/if}
</div> </div>
@@ -370,7 +373,7 @@
<div class="comments {cls}"> <div class="comments {cls}">
{#if loading && comments.length === 0} {#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)} {#each Array(2) as _, i (i)}
<li class="flex gap-3 list-none!"> <li class="flex gap-3 list-none!">
<div class="size-9 shrink-0 rounded-full bg-zinc-200 animate-pulse"></div> <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"> <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" /> <Icon icon="mdi:alert-circle-outline" class="size-5 shrink-0 mt-0.5" />
<div class="flex-1"> <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> <p class="mt-1 text-xs text-red-700">{loadError}</p>
</div> </div>
<button <button
type="button" type="button"
onclick={loadComments} onclick={loadComments}
class="text-xs text-red-800 underline hover:no-underline" class="text-xs text-red-800 underline hover:no-underline"
>Erneut versuchen</button> >{t(T.comments_retry)}</button>
</div> </div>
{:else} {:else}
{#if topLevel.length === 0} {#if topLevel.length === 0}
<div class="flex flex-col items-center gap-2 py-8 text-center"> <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" /> <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> </div>
{:else} {:else}
<ul class="space-y-5 mb-8 list-none p-0"> <ul class="space-y-5 mb-8 list-none p-0">
@@ -423,10 +426,12 @@
> >
<Icon icon="mdi:reply" class="size-3" /> <Icon icon="mdi:reply" class="size-3" />
{replyTo === c.id {replyTo === c.id
? "Abbrechen" ? t(T.comments_cancel)
: replies.length > 0 : replies.length === 0
? `${replies.length} ${replies.length === 1 ? 'Antwort' : 'Antworten'}` ? t(T.comments_reply)
: "Antworten"} : replies.length === 1
? t(T.comments_reply_count_one)
: t(T.comments_reply_count_other, { count: replies.length })}
</button> </button>
</div> </div>
@@ -435,7 +440,7 @@
<input <input
type="text" type="text"
bind:value={replyAuthor} bind:value={replyAuthor}
placeholder="Dein Name" placeholder={t(T.comments_name_placeholder)}
required required
maxlength="80" maxlength="80"
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400" 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} bind:value={replyBody}
use:autosize use:autosize
onkeydown={submitOnCmdEnter} onkeydown={submitOnCmdEnter}
placeholder="Deine Antwort" placeholder={t(T.comments_reply_placeholder)}
required required
rows="2" rows="2"
maxlength={bodyMax} maxlength={bodyMax}
@@ -456,7 +461,7 @@
type="submit" type="submit"
disabled={submitting} 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" 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> <span class="ml-auto text-xs text-zinc-400 tabular-nums">{replyBody.length} / {bodyMax}</span>
</div> </div>
</form> </form>
@@ -486,11 +491,11 @@
{/if} {/if}
<form onsubmit={onSubmit} class="border-t border-zinc-200 pt-5 space-y-3"> <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 <input
type="text" type="text"
bind:value={author} bind:value={author}
placeholder="Dein Name" placeholder={t(T.comments_name_placeholder)}
required required
maxlength="80" maxlength="80"
class="w-full p-2 border border-zinc-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-zinc-400" 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} bind:value={body}
use:autosize use:autosize
onkeydown={submitOnCmdEnter} onkeydown={submitOnCmdEnter}
placeholder="Schreibe etwas Konstruktives. Cmd/Ctrl+Enter sendet." placeholder={t(T.comments_body_placeholder)}
required required
rows="3" rows="3"
maxlength={bodyMax} maxlength={bodyMax}
@@ -522,19 +527,21 @@
disabled={submitting || !body.trim() || !author.trim()} 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" 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> </button>
{#if requireApproval} {#if requireApproval}
<span class="text-zinc-500 inline-flex items-center gap-1"> <span class="text-zinc-500 inline-flex items-center gap-1">
<Icon icon="mdi:shield-check-outline" class="size-3.5" /> <Icon icon="mdi:shield-check-outline" class="size-3.5" />
Wird vor Veröffentlichung geprüft. {t(T.comments_approval_hint)}
</span> </span>
{/if} {/if}
<span class="ml-auto text-zinc-400 tabular-nums">{body.length} / {bodyMax}</span> <span class="ml-auto text-zinc-400 tabular-nums">{body.length} / {bodyMax}</span>
</div> </div>
{#if totalPending > 0} {#if totalPending > 0}
<p class="text-xs text-zinc-500 italic"> <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> </p>
{/if} {/if}
</form> </form>
+5 -2
View File
@@ -6,6 +6,9 @@
import EventBadges from "./EventBadges.svelte"; import EventBadges from "./EventBadges.svelte";
import RustyImage from "./RustyImage.svelte"; import RustyImage from "./RustyImage.svelte";
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import { useTranslate, T } from "$lib/translations";
const t = useTranslate();
type PostWithImage = PostEntry & { type PostWithImage = PostEntry & {
_resolvedImageUrl?: string; _resolvedImageUrl?: string;
@@ -98,8 +101,8 @@
<div class="absolute top-2 left-2"> <div class="absolute top-2 left-2">
<span <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" 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" aria-label={t(T.comments_count_aria, { count: commentCount })}
title="{commentCount} Kommentare" title={t(T.comments_count_aria, { count: commentCount })}
> >
<Icon icon="mdi:comment-outline" class="size-3" /> <Icon icon="mdi:comment-outline" class="size-3" />
{commentCount} {commentCount}
+29
View File
@@ -133,6 +133,35 @@ const TRANSLATION_KEYS = [
"post_map_activate", "post_map_activate",
"post_map_open_osm", "post_map_open_osm",
"post_comments", "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; ] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number]; export type TranslationKey = (typeof TRANSLATION_KEYS)[number];