i18n implemented

This commit is contained in:
2026-10-03 23:45:04 +02:00
parent 2ccea34bb9
commit d95ed40053
55 changed files with 2952 additions and 301 deletions
+45 -44
View File
@@ -65,6 +65,7 @@ const id = computed(() => route.params.id as string)
// Twitch-Embeds brauchen den Hostname als parent-Parameter
const requestHost = useRequestURL().hostname
const { user, can } = useAuth()
const { t, locale } = useI18n()
// Beim Client-Side-Nav nicht awaiten, damit die Seite sofort rendert und das
// benannte Medium-Element für die View-Transition im Snapshot vorhanden ist
@@ -171,7 +172,7 @@ onUnmounted(() => {
async function reactToUpload(emoji: string) {
if (!user.value) {
toast.error('Melde dich an, um zu reagieren.')
toast.error(t('comments.loginToReact'))
return
}
if (reactionBusy.value) return
@@ -183,7 +184,7 @@ async function reactToUpload(emoji: string) {
reactionPickerOpen.value = false
}
catch {
toast.error('Reaktion fehlgeschlagen')
toast.error(t('comments.reactionFailed'))
}
finally {
reactionBusy.value = false
@@ -305,11 +306,11 @@ function pendingPreview(file: File): string | null {
function addPendingFiles(files: File[]) {
for (const file of files) {
if (pendingFiles.value.length >= MAX_COMMENT_ATTACHMENTS) {
toast.error(`Maximal ${MAX_COMMENT_ATTACHMENTS} Anhänge pro Kommentar`)
toast.error(t('comments.maxAttachments', { n: MAX_COMMENT_ATTACHMENTS }))
break
}
if (file.size > MAX_ATTACHMENT_SIZE) {
toast.error(`${file.name}: Datei ist zu groß (max. 200 MB)`)
toast.error(t('comments.fileTooLarge', { name: file.name }))
continue
}
pendingFiles.value.push(file)
@@ -362,7 +363,7 @@ async function startRecording() {
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true })
}
catch {
toast.error('Kein Zugriff aufs Mikrofon')
toast.error(t('comments.noMic'))
return
}
const mimeType = recorderMimeType()
@@ -376,7 +377,7 @@ async function startRecording() {
if (!recordDiscarded && recordChunks.length && pendingFiles.value.length < MAX_COMMENT_ATTACHMENTS) {
const type = recorder?.mimeType || 'audio/webm'
const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : 'webm'
const file = new File(recordChunks, `Sprachnachricht.${ext}`, { type })
const file = new File(recordChunks, `${t('comments.voiceMessage')}.${ext}`, { type })
pendingFiles.value.push(file)
voicePending.value.add(file)
}
@@ -466,7 +467,7 @@ async function saveTags() {
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? 'Tags konnten nicht gespeichert werden')
toast.error(e.data?.statusMessage ?? t('thread.tagsSaveFailed'))
}
finally {
tagSaving.value = false
@@ -476,7 +477,7 @@ async function saveTags() {
async function toggleFavorite() {
if (!data.value || favBusy.value) return
if (!user.value) {
toast.error('Melde dich an, um zu favorisieren.')
toast.error(t('thread.loginToFavorite'))
return
}
favBusy.value = true
@@ -501,7 +502,7 @@ watch(id, () => {
const timeAgo = useTimeAgo()
function formatDate(value: string) {
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
}
async function submitComment() {
@@ -533,7 +534,7 @@ async function submitComment() {
}
catch (err) {
const e = err as { data?: { statusMessage?: string, message?: string } }
commentError.value = e.data?.statusMessage ?? e.data?.message ?? 'Kommentar konnte nicht gesendet werden'
commentError.value = e.data?.statusMessage ?? e.data?.message ?? t('comments.sendFailed')
}
finally {
posting.value = false
@@ -551,7 +552,7 @@ async function deleteUpload() {
await navigateTo('/')
}
catch {
toast.error('Löschen fehlgeschlagen')
toast.error(t('thread.deleteFailed'))
}
finally {
deleting.value = false
@@ -569,7 +570,7 @@ async function deleteComment(commentId: number) {
<template>
<div class="px-4 lg:px-6">
<div v-if="error" class="text-muted-foreground">
Upload nicht gefunden.
{{ $t('thread.notFound') }}
</div>
<template v-else>
@@ -582,7 +583,7 @@ async function deleteComment(commentId: number) {
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full shadow opacity-80 hover:opacity-100"
as-child
>
<NuxtLink :to="`/thread/${data.newerId}`" aria-label="Neuerer Upload">
<NuxtLink :to="`/thread/${data.newerId}`" :aria-label="$t('thread.newer')">
<Icon name="ph:caret-left-fill" class="size-5" />
</NuxtLink>
</Button>
@@ -593,7 +594,7 @@ async function deleteComment(commentId: number) {
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full shadow opacity-80 hover:opacity-100"
as-child
>
<NuxtLink :to="`/thread/${data.olderId}`" aria-label="Älterer Upload">
<NuxtLink :to="`/thread/${data.olderId}`" :aria-label="$t('thread.older')">
<Icon name="ph:caret-right-fill" class="size-5" />
</NuxtLink>
</Button>
@@ -647,7 +648,7 @@ async function deleteComment(commentId: number) {
class="group relative flex cursor-pointer items-center justify-center"
:class="media.previewImage ? 'aspect-square max-h-[70vh] w-full' : 'h-56 sm:h-64'"
role="button"
:aria-label="audioPlaying ? 'Pause' : 'Abspielen'"
:aria-label="audioPlaying ? $t('audio.pause') : $t('audio.play')"
@click="toggleAudio"
>
<Icon name="ph:music-note-fill" v-if="!media.previewImage" class="size-16 text-muted-foreground" />
@@ -681,7 +682,7 @@ async function deleteComment(commentId: number) {
<button
type="button"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-white transition-colors hover:bg-white/15"
:aria-label="audioPlaying ? 'Pause' : 'Abspielen'"
:aria-label="audioPlaying ? $t('audio.pause') : $t('audio.play')"
@click="toggleAudio"
>
<Icon name="ph:pause-fill" v-if="audioPlaying" class="size-4" />
@@ -694,7 +695,7 @@ async function deleteComment(commentId: number) {
:max="Number.isFinite(audioDuration) ? audioDuration : 0"
step="0.1"
:value="audioTime"
aria-label="Wiedergabeposition"
:aria-label="$t('audio.seek')"
class="min-w-0 flex-1 cursor-pointer accent-primary"
@input="seekAudio"
>
@@ -702,7 +703,7 @@ async function deleteComment(commentId: number) {
<button
type="button"
class="hidden size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-white transition-colors hover:bg-white/15 sm:flex"
:aria-label="audioMuted ? 'Ton an' : 'Stumm'"
:aria-label="audioMuted ? $t('audio.unmute') : $t('audio.mute')"
@click="audioMuted = !audioMuted"
>
<Icon name="ph:speaker-x-fill" v-if="audioMuted" class="size-4" />
@@ -714,7 +715,7 @@ async function deleteComment(commentId: number) {
min="0"
max="1"
step="0.05"
aria-label="Lautstärke"
:aria-label="$t('audio.volume')"
class="hidden w-20 cursor-pointer accent-primary sm:block"
:class="audioMuted && 'opacity-40'"
>
@@ -798,7 +799,7 @@ async function deleteComment(commentId: number) {
type="button"
class="ml-auto flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
:disabled="favBusy"
:aria-label="data.isFavorited ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen'"
:aria-label="data.isFavorited ? $t('thread.favRemove') : $t('thread.favAdd')"
@click="toggleFavorite"
@pointerenter="startReactionHover"
@pointerleave="scheduleReactionClose"
@@ -839,7 +840,7 @@ async function deleteComment(commentId: number) {
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-destructive"
aria-label="Upload löschen"
:aria-label="$t('thread.deleteUpload')"
@click="deleteConfirmOpen = true"
>
<Icon name="ph:trash-fill" class="size-4" />
@@ -847,17 +848,17 @@ async function deleteComment(commentId: number) {
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
Upload wirklich löschen?
{{ $t('thread.deleteTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
Der Beitrag und alle Kommentare werden entfernt.
{{ $t('thread.deleteDesc') }}
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="deleting" @click="deleteConfirmOpen = false">
Abbrechen
{{ $t('common.cancel') }}
</Button>
<Button variant="destructive" size="sm" :disabled="deleting" @click="deleteUpload">
{{ deleting ? 'Löscht…' : 'Löschen' }}
{{ deleting ? $t('common.deleting') : $t('common.delete') }}
</Button>
</div>
</PopoverContent>
@@ -920,7 +921,7 @@ async function deleteComment(commentId: number) {
v-if="canDeleteTags || !originalTags.has(t)"
type="button"
class="text-muted-foreground hover:text-foreground"
:aria-label="`Tag ${t} entfernen`"
:aria-label="$t('thread.removeTag', { tag: t })"
@click="tagDraft = tagDraft.filter(x => x !== t)"
>
<Icon name="ph:x-fill" class="size-3.5" />
@@ -930,7 +931,7 @@ async function deleteComment(commentId: number) {
v-model="tagInput"
list="tag-suggestions"
type="text"
placeholder="Tag hinzufügen…"
:placeholder="$t('thread.addTag')"
class="min-w-32 flex-1 bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
@keydown="onTagKeydown"
>
@@ -940,10 +941,10 @@ async function deleteComment(commentId: number) {
</div>
<div class="mt-2.5 flex items-center gap-2">
<Button size="sm" :disabled="tagSaving" @click="saveTags">
{{ tagSaving ? 'Speichern…' : 'Speichern' }}
{{ tagSaving ? $t('common.saving') : $t('common.save') }}
</Button>
<Button size="sm" variant="ghost" :disabled="tagSaving" @click="tagEditing = false">
Abbrechen
{{ $t('common.cancel') }}
</Button>
</div>
</div>
@@ -959,7 +960,7 @@ async function deleteComment(commentId: number) {
v-if="canEditTags"
type="button"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Tags bearbeiten"
:aria-label="$t('thread.editTags')"
@click="startTagEdit"
>
<Icon name="ph:pencil-fill" class="size-4" />
@@ -976,7 +977,7 @@ async function deleteComment(commentId: number) {
<section class="mx-auto mt-6 w-full lg:w-2/5">
<h2 class="mb-3 font-semibold">
Kommentare ({{ allComments.length }})
{{ $t('comments.title') }} ({{ allComments.length }})
</h2>
<ul v-if="allComments.length" class="space-y-3">
@@ -1033,7 +1034,7 @@ async function deleteComment(commentId: number) {
@click="deleteComment(comment.id)"
>
<Icon name="ph:trash-fill" class="size-4" />
<span class="sr-only">Kommentar löschen</span>
<span class="sr-only">{{ $t('comments.deleteComment') }}</span>
</button>
</div>
</div>
@@ -1045,7 +1046,7 @@ async function deleteComment(commentId: number) {
</li>
</ul>
<p v-else class="text-sm text-muted-foreground">
Noch keine Kommentare.
{{ $t('comments.none') }}
</p>
<form
@@ -1057,7 +1058,7 @@ async function deleteComment(commentId: number) {
<div :class="cn('rounded-md transition-shadow', isOverCommentDrop && 'shadow-[0_0_0_2px_var(--color-primary)]')">
<MentionTextarea
v-model="newComment"
placeholder="Kommentar schreiben… @ zum Erwähnen, Dateien hierher ziehen"
:placeholder="$t('comments.placeholder')"
>
<div class="absolute right-2 bottom-2 flex items-center gap-0.5">
<Tooltip>
@@ -1066,13 +1067,13 @@ async function deleteComment(commentId: number) {
type="button"
class="flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent"
:disabled="posting || pendingFiles.length >= MAX_COMMENT_ATTACHMENTS"
aria-label="Dateien anhängen"
:aria-label="$t('comments.attachFiles')"
@click="fileInputRef?.click()"
>
<Icon name="ph:folder-plus-fill" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>Dateien anhängen</TooltipContent>
<TooltipContent>{{ $t('comments.attachFiles') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="canRecord">
<TooltipTrigger as-child>
@@ -1083,13 +1084,13 @@ async function deleteComment(commentId: number) {
recording && 'text-destructive hover:text-destructive',
)"
:disabled="posting || pendingFiles.length >= MAX_COMMENT_ATTACHMENTS"
:aria-label="recording ? 'Aufnahme beenden' : 'Sprachnachricht aufnehmen'"
:aria-label="recording ? $t('comments.recordStop') : $t('comments.recordStart')"
@click="recording ? stopRecording() : startRecording()"
>
<Icon :name="recording ? 'ph:stop-fill' : 'ph:microphone-fill'" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ recording ? 'Aufnahme beenden' : 'Sprachnachricht aufnehmen' }}</TooltipContent>
<TooltipContent>{{ recording ? $t('comments.recordStop') : $t('comments.recordStart') }}</TooltipContent>
</Tooltip>
</div>
</MentionTextarea>
@@ -1115,7 +1116,7 @@ async function deleteComment(commentId: number) {
<button
type="button"
class="shrink-0 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-destructive"
aria-label="Anhang entfernen"
:aria-label="$t('comments.removeAttachment')"
@click="removePendingFile(i)"
>
<Icon name="ph:x" class="size-3.5" />
@@ -1131,8 +1132,8 @@ async function deleteComment(commentId: number) {
<span class="relative inline-flex size-2.5 rounded-full bg-destructive" />
</span>
<span class="shrink-0 tabular-nums">{{ formatAudioTime(recordSeconds) }}</span>
<span class="text-muted-foreground">Aufnahme läuft…</span>
<span class="ml-auto text-muted-foreground">Zum Beenden Mikrofon klicken</span>
<span class="text-muted-foreground">{{ $t('comments.recording') }}</span>
<span class="ml-auto text-muted-foreground">{{ $t('comments.recordHint') }}</span>
</div>
</div>
<div class="flex items-center gap-3">
@@ -1145,14 +1146,14 @@ async function deleteComment(commentId: number) {
>
<span v-if="commentError" class="text-sm text-destructive">{{ commentError }}</span>
<Button type="submit" size="sm" class="ml-auto" :disabled="posting || (!newComment.trim() && !pendingFiles.length)">
Kommentieren
{{ $t('comments.submit') }}
</Button>
</div>
</form>
<p v-else class="mt-4 text-sm text-muted-foreground">
<NuxtLink to="/login" class="text-primary underline">
Melde dich an
</NuxtLink>, um zu kommentieren.
{{ $t('common.loginLink') }}
</NuxtLink>, {{ $t('comments.loginToComment') }}
</p>
</section>
</template>