From d95ed400535414b58f7faac39fc5b91b6b9b765c Mon Sep 17 00:00:00 2001 From: FliesentischKing Date: Sat, 3 Oct 2026 23:45:04 +0200 Subject: [PATCH] i18n implemented --- AGENTS.md | 1 + README.md | 2 + app/app.vue | 3 +- app/components/AudioPlayerDialog.vue | 10 +- app/components/CommandPalette.vue | 38 +- app/components/CommandPaletteResults.vue | 4 +- app/components/CommentAttachments.vue | 4 +- app/components/CommentContent.vue | 2 +- app/components/CommentReactionPicker.vue | 5 +- app/components/CommentReactions.vue | 5 +- app/components/CommentsSidebar.vue | 2 +- app/components/DataTable.vue | 8 +- app/components/FileDropzone.vue | 12 +- app/components/ImageLightbox.vue | 2 +- app/components/MentionTextarea.vue | 4 +- app/components/NotificationBell.vue | 6 +- app/components/SiteHeader.vue | 20 +- app/components/ThreadSubscriptionButton.vue | 9 +- app/components/UploadFilters.vue | 72 +- app/components/UploadPopover.vue | 21 +- app/components/UploadReactions.vue | 5 +- app/components/UploadTile.vue | 6 +- app/components/UserHoverCard.vue | 9 +- app/components/VoiceMessagePlayer.vue | 12 +- app/components/ui/breadcrumb/Breadcrumb.vue | 2 +- .../ui/breadcrumb/BreadcrumbEllipsis.vue | 2 +- .../ui/color-picker/ColorPicker.vue | 2 +- app/components/ui/dialog/DialogContent.vue | 2 +- .../ui/dialog/DialogScrollContent.vue | 2 +- .../ui/pagination/PaginationEllipsis.vue | 2 +- .../ui/pagination/PaginationFirst.vue | 4 +- .../ui/pagination/PaginationItem.vue | 2 +- .../ui/pagination/PaginationLast.vue | 4 +- .../ui/pagination/PaginationNext.vue | 4 +- .../ui/pagination/PaginationPrevious.vue | 4 +- app/components/ui/sheet/SheetContent.vue | 2 +- app/components/ui/sidebar/Sidebar.vue | 4 +- app/components/ui/sidebar/SidebarRail.vue | 4 +- app/components/ui/sidebar/SidebarTrigger.vue | 2 +- app/composables/useTimeAgo.ts | 13 +- app/pages/admin.vue | 57 +- app/pages/index.vue | 15 +- app/pages/login.vue | 17 +- app/pages/register.vue | 19 +- app/pages/settings.vue | 75 +- app/pages/thread/[id].vue | 89 +- app/pages/user/[id].vue | 21 +- app/pages/users.vue | 16 +- app/plugins/notifications.client.ts | 5 +- i18n/locales/de.json | 263 +++ i18n/locales/en.json | 263 +++ nuxt.config.ts | 16 +- package-lock.json | 2079 ++++++++++++++++- package.json | 1 + screenshot.png | Bin 0 -> 897074 bytes 55 files changed, 2952 insertions(+), 301 deletions(-) create mode 100644 i18n/locales/de.json create mode 100644 i18n/locales/en.json create mode 100644 screenshot.png diff --git a/AGENTS.md b/AGENTS.md index 725a0a3..ee2701d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,3 +15,4 @@ - Kommentar-Reactions: `useCommentReactions` (State-Map pro Comment-Id), Emoji-Liste `REACTION_EMOJIS`. - Thread-Benachrichtigungen: `threadSubscriptions` speichert Abos und explizite Deaktivierungen. Erstellen, Kommentieren, Favorisieren und Post-Reactions rufen `ensureThreadSubscription()` auf; ein deaktiviertes Abo darf dadurch nicht reaktiviert werden. `notifyComment()` benachrichtigt aktive Abonnenten und erwähnte Nutzer, respektiert Deaktivierungen auch bei Erwähnungen und fasst Meldungen pro Nutzer/Thread zusammen. - Berechtigungen: Katalog in `shared/utils/permissions.ts` (`PERMISSIONS`), Server-Check per `requirePermission(event, key)` in `server/utils/auth.ts` — `admin`-Rolle ist implizit wildcard. Client-seitig `can(key)` aus `useAuth`. Seeding via `seedPermissions()` im Migrations-Plugin/Script. +- i18n: `@nuxtjs/i18n`, Locales `de` (Default) + `en` in `i18n/locales/*.json` — beide Dateien immer gleiche Key-Struktur halten. Alle UI-Strings über `$t()`/`t()`, nie hardcoden. `strategy: 'no_prefix'`, Locale-Cookie `i18n_locale`. Permission-Labels im Admin über `admin.perms.`; Effekt-/Theme-Namen bleiben Eigennamen. diff --git a/README.md b/README.md index 06b797d..36e6479 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ An imageboard-style upload and discussion platform built with **Nuxt 4** and **shadcn-vue**. Users can upload files or URLs, comment in threads (including file attachments and voice messages), respond with Discord-style reactions, subscribe to threads, and pick from over 20 themes. +![Screenshot of the upload grid](screenshot.png) + ## Features - **Uploads:** Drag & drop (page dropzone + upload popover), file or URL upload, max. 200 MB. Types: **images** (generated thumbnails), **videos** (embedded Plyr player), **audio** (MP3/FLAC with embedded cover art and music metadata via `music-metadata`, dedicated player dialog), **YouTube** and **Twitch embeds**, plus **link preview cards** diff --git a/app/app.vue b/app/app.vue index b610d37..7a582cc 100644 --- a/app/app.vue +++ b/app/app.vue @@ -3,8 +3,9 @@ import 'vue-sonner/style.css' import { Toaster } from '@/components/ui/sonner' const theme = useTheme() +const { locale } = useI18n() useHead({ - htmlAttrs: { 'data-theme': theme }, + htmlAttrs: { 'data-theme': theme, lang: locale }, }) diff --git a/app/components/AudioPlayerDialog.vue b/app/components/AudioPlayerDialog.vue index 2eba4a5..bbb2b40 100644 --- a/app/components/AudioPlayerDialog.vue +++ b/app/components/AudioPlayerDialog.vue @@ -77,7 +77,7 @@ function formatTime(sec: number) { diff --git a/app/components/CommandPalette.vue b/app/components/CommandPalette.vue index d074936..21584ce 100644 --- a/app/components/CommandPalette.vue +++ b/app/components/CommandPalette.vue @@ -30,44 +30,44 @@ async function go(to: string) { diff --git a/app/components/ImageLightbox.vue b/app/components/ImageLightbox.vue index b9d7e04..37f684d 100644 --- a/app/components/ImageLightbox.vue +++ b/app/components/ImageLightbox.vue @@ -74,7 +74,7 @@ onKeyStroke('Escape', () => emit('close')) @click="emit('close')" > - Schließen + {{ $t('common.close') }} diff --git a/app/components/MentionTextarea.vue b/app/components/MentionTextarea.vue index d29f335..0c3e347 100644 --- a/app/components/MentionTextarea.vue +++ b/app/components/MentionTextarea.vue @@ -69,7 +69,7 @@ function onKeydown(event: KeyboardEvent) { v-model="model" :placeholder="props.placeholder" class="min-h-32 pb-9" - aria-label="Kommentar" + :aria-label="$t('comments.commentAria')" aria-autocomplete="list" :aria-expanded="open" :aria-controls="open ? listId : undefined" @@ -92,7 +92,7 @@ function onKeydown(event: KeyboardEvent) { @close-auto-focus.prevent @interact-outside="event => { if (event.target === textarea) event.preventDefault() }" > -
+

diff --git a/app/components/SiteHeader.vue b/app/components/SiteHeader.vue index 0f0a5dd..384ec0a 100644 --- a/app/components/SiteHeader.vue +++ b/app/components/SiteHeader.vue @@ -44,7 +44,7 @@ async function onLogout() {

@@ -75,7 +75,7 @@ async function onLogout() { - {{ uniform ? 'Bento-Layout' : 'Einheitliche Größe' }} + {{ uniform ? $t('nav.gridBento') : $t('nav.gridUniform') }}
diff --git a/app/components/ThreadSubscriptionButton.vue b/app/components/ThreadSubscriptionButton.vue index b05d0bd..9b0a335 100644 --- a/app/components/ThreadSubscriptionButton.vue +++ b/app/components/ThreadSubscriptionButton.vue @@ -6,9 +6,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip const props = defineProps<{ uploadId: string, enabled: boolean }>() const emit = defineEmits<{ change: [uploadId: string, enabled: boolean] }>() const busy = shallowRef(false) +const { t } = useI18n() const label = computed(() => props.enabled - ? 'Benachrichtigungen für diesen Thread deaktivieren' - : 'Benachrichtigungen für diesen Thread aktivieren') + ? t('thread.subscribeOff') + : t('thread.subscribeOn')) async function toggle() { if (busy.value) return @@ -20,10 +21,10 @@ async function toggle() { body: { enabled: !props.enabled }, }) emit('change', uploadId, result.enabled) - toast.success(result.enabled ? 'Thread-Benachrichtigungen aktiviert' : 'Thread-Benachrichtigungen deaktiviert') + toast.success(result.enabled ? t('thread.subOn') : t('thread.subOff')) } catch { - toast.error('Abonnement konnte nicht geändert werden') + toast.error(t('thread.subFailed')) } finally { busy.value = false diff --git a/app/components/UploadFilters.vue b/app/components/UploadFilters.vue index 3c8cd8c..6c9580b 100644 --- a/app/components/UploadFilters.vue +++ b/app/components/UploadFilters.vue @@ -25,21 +25,22 @@ interface ListedUser { const route = useRoute() const router = useRouter() +const { t } = useI18n() const TYPE_OPTIONS = [ - { value: 'image', label: 'Bilder', icon: 'ph:image-fill' }, - { value: 'video', label: 'Videos', icon: 'ph:video-camera-fill' }, - { value: 'audio', label: 'Audio', icon: 'ph:music-note-fill' }, - { value: 'youtube', label: 'YouTube', icon: 'simple-icons:youtube' }, - { value: 'twitch', label: 'Twitch', icon: 'simple-icons:twitch' }, - { value: 'link', label: 'Links', icon: 'ph:link-fill' }, + { value: 'image', labelKey: 'filters.images', icon: 'ph:image-fill' }, + { value: 'video', labelKey: 'filters.videos', icon: 'ph:video-camera-fill' }, + { value: 'audio', labelKey: 'filters.audio', icon: 'ph:music-note-fill' }, + { value: 'youtube', labelKey: null, icon: 'simple-icons:youtube' }, + { value: 'twitch', labelKey: null, icon: 'simple-icons:twitch' }, + { value: 'link', labelKey: 'filters.links', icon: 'ph:link-fill' }, ] as const const DAY_OPTIONS = [ - { value: '1', label: 'Heute' }, - { value: '7', label: '7 Tage' }, - { value: '30', label: '30 Tage' }, - { value: '365', label: 'Letztes Jahr' }, + { value: '1', labelKey: 'filters.today' }, + { value: '7', labelKey: 'filters.days7' }, + { value: '30', labelKey: 'filters.days30' }, + { value: '365', labelKey: 'filters.lastYear' }, ] as const const open = ref(false) @@ -79,7 +80,10 @@ const daysModel = computed({ }) const daysOption = computed(() => DAY_OPTIONS.find(d => d.value === daysModel.value)) -const typeLabel = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.label ?? value +const typeLabel = (value: string) => { + const option = TYPE_OPTIONS.find(o => o.value === value) + return option ? (option.labelKey ? t(option.labelKey) : option.value) : value +} const typeIcon = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.icon ?? 'ph:image-fill' const activeCount = computed(() => @@ -111,36 +115,36 @@ function clearFilters() { - Typ + {{ $t('filters.type') }} - - {{ t.label }} + + {{ typeLabel(o.value) }} - Uploader + {{ $t('filters.uploader') }} @@ -211,7 +215,7 @@ function clearFilters() {

- Melde dich an - , um Dateien hochzuladen. + {{ $t('common.loginLink') }} + {{ $t('upload.loginToUpload') }}

diff --git a/app/components/UploadReactions.vue b/app/components/UploadReactions.vue index c7907f5..f41f420 100644 --- a/app/components/UploadReactions.vue +++ b/app/components/UploadReactions.vue @@ -8,6 +8,7 @@ const emit = defineEmits<{ subscriptionChange: [uploadId: string, enabled: boole const { user } = useAuth() const { reactionsFor, toggle } = useUploadReactions() +const { t } = useI18n() const busy = ref(false) const pending = ref(null) @@ -22,7 +23,7 @@ const burstNonce = ref(0) async function pick(emoji: string) { if (!user.value) { - toast.error('Melde dich an, um zu reagieren.') + toast.error(t('comments.loginToReact')) return } if (busy.value) return @@ -36,7 +37,7 @@ async function pick(emoji: string) { emit('subscriptionChange', uploadId, result.isSubscribed) } catch { - toast.error('Reaktion fehlgeschlagen') + toast.error(t('comments.reactionFailed')) } finally { busy.value = false diff --git a/app/components/UploadTile.vue b/app/components/UploadTile.vue index 67f041c..a1a869b 100644 --- a/app/components/UploadTile.vue +++ b/app/components/UploadTile.vue @@ -1,5 +1,6 @@ @@ -78,18 +79,18 @@ function formatDate(value: string) {

- seit {{ formatDate(summary.createdAt) }} + {{ $t('misc.since', { date: formatDate(summary.createdAt) }) }}

- {{ summary.stats.uploads }} Uploads + {{ summary.stats.uploads }} {{ $t('misc.uploadsCol') }} - {{ summary.stats.comments }} Kommentare + {{ summary.stats.comments }} {{ $t('misc.commentsCol') }}
diff --git a/app/components/VoiceMessagePlayer.vue b/app/components/VoiceMessagePlayer.vue index 0f571de..df71f9e 100644 --- a/app/components/VoiceMessagePlayer.vue +++ b/app/components/VoiceMessagePlayer.vue @@ -34,7 +34,7 @@ function formatTime(sec: number) { diff --git a/app/components/ui/dialog/DialogScrollContent.vue b/app/components/ui/dialog/DialogScrollContent.vue index 1a4adea..788c26d 100644 --- a/app/components/ui/dialog/DialogScrollContent.vue +++ b/app/components/ui/dialog/DialogScrollContent.vue @@ -51,7 +51,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary" > - Close + {{ $t('common.close') }} diff --git a/app/components/ui/pagination/PaginationEllipsis.vue b/app/components/ui/pagination/PaginationEllipsis.vue index 673bd4f..cb0c6a9 100644 --- a/app/components/ui/pagination/PaginationEllipsis.vue +++ b/app/components/ui/pagination/PaginationEllipsis.vue @@ -19,7 +19,7 @@ const delegatedProps = reactiveOmit(props, 'class') > - Weitere Seiten + {{ $t('pagination.more') }} diff --git a/app/components/ui/pagination/PaginationFirst.vue b/app/components/ui/pagination/PaginationFirst.vue index a6a3299..b8613cd 100644 --- a/app/components/ui/pagination/PaginationFirst.vue +++ b/app/components/ui/pagination/PaginationFirst.vue @@ -22,13 +22,13 @@ const forwarded = useForwardProps(delegatedProps) diff --git a/app/components/ui/pagination/PaginationItem.vue b/app/components/ui/pagination/PaginationItem.vue index 96e1a38..b506e49 100644 --- a/app/components/ui/pagination/PaginationItem.vue +++ b/app/components/ui/pagination/PaginationItem.vue @@ -21,7 +21,7 @@ const delegatedProps = reactiveOmit(props, 'class', 'size', 'isActive')