From 1d8010f5777b4228cdf325b53a38280de6d12207 Mon Sep 17 00:00:00 2001 From: Hellraiser28 Date: Sat, 10 Oct 2026 13:25:42 +0200 Subject: [PATCH 1/3] Add German translation and browser language detection --- README.md | 3 + messages/de.json | 536 ++++++++++++++++++++++++++++++++++++++++ src/app/layout.tsx | 7 +- src/i18n/locale.test.ts | 63 +++++ src/i18n/locale.ts | 31 +++ src/i18n/request.ts | 4 +- 6 files changed, 641 insertions(+), 3 deletions(-) create mode 100644 messages/de.json create mode 100644 src/i18n/locale.test.ts create mode 100644 src/i18n/locale.ts diff --git a/README.md b/README.md index 32466b1b..f05754bd 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,9 @@ Tagr is fully responsive and works on phones and tablets. The mobile UI adapts t - **Multi-user authentication** — password-protected access with roles (admin, tagger, listener) - **Resizable panels** — drag to resize the three-panel layout to your liking - **Dark theme** by default +- **English and German UI** — the language follows the browser's `Accept-Language` setting and falls back to + English. Translations live in `messages/.json`; a new language needs its file plus an entry in + `src/i18n/locale.ts` - **Toast notifications** for operation feedback - **URL-based state** — bookmarkable views with folder, song, sort, and filter state preserved in the URL diff --git a/messages/de.json b/messages/de.json new file mode 100644 index 00000000..a0a1b905 --- /dev/null +++ b/messages/de.json @@ -0,0 +1,536 @@ +{ + "nav": { + "home": "Start", + "settings": "Einstellungen" + }, + "navigation": { + "folders": "Ordner", + "library": "Bibliothek", + "songs": "Songs", + "details": "Details" + }, + "common": { + "loading": "Wird geladen...", + "error": "Ein Fehler ist aufgetreten", + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "duplicate": "Duplizieren", + "search": "Suchen", + "logout": "Abmelden", + "unknown": "Unbekannt", + "more": "Mehr", + "less": "Weniger" + }, + "folders": { + "title": "Tagr", + "subtitle": "Verwaltung der Musikbibliothek", + "empty": "Keine Ordner konfiguriert", + "notFound": "Keine Ordner gefunden", + "searchPlaceholder": "Ordner durchsuchen...", + "totalFolders": "{count} Ordner", + "totalFiles": "{count, plural, =0 {Keine Dateien} one {# Datei} other {# Dateien}}", + "totalSubfolders": "{count, plural, =0 {Keine Unterordner} one {# Unterordner} other {# Unterordner}}", + "allFolders": "Alle Ordner", + "selectFolder": "Ordner oder Playlist auswählen", + "selectFolderDescription": "Wähle in der Seitenleiste einen Ordner oder eine intelligente Playlist, um die Musikdateien anzuzeigen", + "browseFolders": "Bibliothek anzeigen", + "loadingFolders": "Ordner werden geladen...", + "errorLoading": "Fehler beim Laden", + "errorLoadingDescription": "Die Ordner konnten nicht geladen werden", + "file": "{count, plural, one {Datei} other {Dateien}}", + "files": "{count} Dateien", + "subfolder": "{count, plural, one {Unterordner} other {Unterordner}}", + "subfolders": "{count} Unterordner", + "quickScan": "Schnellscan", + "quickScanDescription": "Scannt nur neue oder geänderte Dateien", + "quickScanConfirmTitle": "Schnellscan starten?", + "quickScanConfirmDescription": "Verarbeitet nur neue Dateien und Dateien, deren Änderungsdatum sich geändert hat. Ideal, wenn du neue Musik hinzugefügt oder Dateien ersetzt hast. Dateien, die mit einem anderen Programm bearbeitet wurden, ohne dass sich das Änderungsdatum geändert hat, werden nicht erkannt.", + "fullScan": "Vollständiger Scan", + "fullScanDescription": "Scannt alle Dateien erneut und aktualisiert die Metadaten", + "fullScanConfirmTitle": "Vollständigen Scan starten?", + "fullScanConfirmDescription": "Liest die Metadaten aller Dateien deiner Bibliothek neu ein und aktualisiert die Datenbank. Nutze das, wenn du Tags mit einem anderen Programm bearbeitet hast (z. B. MusicBrainz Picard, Mp3tag), wenn du vermutest, dass die Datenbank nicht mehr stimmt, oder nach einem Tagr-Update. Bei großen Bibliotheken kann das eine Weile dauern.", + "skipped": "übersprungen", + "rescan": "Bibliothek neu scannen", + "history": "Änderungsverlauf anzeigen", + "feedback": "Feedback senden", + "installApp": "App installieren", + "appInstalled": "App installiert", + "manageUsers": "Benutzer verwalten", + "scanning": "Bibliothek wird gescannt...", + "scanningProgress": "Bibliothek wird gescannt... ({current}/{total})", + "scanCompleted": "Scan abgeschlossen", + "scanFailed": "Scan fehlgeschlagen", + "filesScanned": "Dateien gescannt", + "added": "hinzugefügt", + "updated": "aktualisiert", + "deleted": "gelöscht", + "errors": "Fehler", + "viewDetails": "Details", + "unknownError": "Ein unbekannter Fehler ist aufgetreten" + }, + "scanSummary": { + "title": "Scan-Zusammenfassung", + "totalFiles": "{count, plural, one {# Datei verarbeitet} other {# Dateien verarbeitet}}" + }, + "summary": { + "added": "Hinzugefügt", + "updated": "Aktualisiert", + "cover": "Aktualisiert", + "deleted": "Gelöscht", + "skipped": "Übersprungen", + "errors": "Fehler", + "andMore": "…und {count, plural, one {# weitere} other {# weitere}}" + }, + "files": { + "title": "Dateien", + "empty": "Keine Musikdateien gefunden", + "emptyDescription": "Dieser Ordner bzw. diese intelligente Playlist enthält keine Musikdateien. Scanne deine Bibliothek oder wähle eine andere Quelle.", + "syncNow": "Jetzt scannen", + "details": "Dateidetails", + "selectFile": "Wähle eine Datei aus, um ihre Details zu sehen", + "fileInfo": "Dateiinformationen", + "searchPlaceholder": "Songs nach Titel, Interpret, Album, Label, Kommentar oder Dateiname durchsuchen...", + "filterPlaceholder": "Filtern...", + "filterPlaceholderPressEnter": "Filtern (Enter)...", + "dateFilterPlaceholder": "Datum auswählen...", + "noFilterResults": "Keine Songs entsprechen den aktuellen Filtern", + "clearFilters": "Filter zurücksetzen", + "clearDateFilter": "Zurücksetzen", + "applyDateFilter": "Übernehmen", + "booleanFilterAll": "Alle", + "booleanFilterYes": "Ja", + "booleanFilterNo": "Nein", + "selectFilterAll": "Alle", + "selectFilterSelected": "{count} ausgewählt" + }, + "musicInfo": { + "title": "Song-Info" + }, + "trackInfo": { + "title": "Track-Info", + "of": "von" + }, + "audioProperties": { + "title": "Audio-Eigenschaften" + }, + "notes": { + "title": "Notizen" + }, + "textareaEdit": { + "importFromFile": "Aus Datei importieren", + "importError": "Die Datei konnte nicht gelesen werden", + "saveHint": "⌘/Strg + Enter zum Speichern" + }, + "customTags": { + "title": "Eigene Tags", + "addTag": "Tag hinzufügen", + "key": "Schlüssel", + "value": "Wert", + "empty": "Keine eigenen Tags", + "keyPlaceholder": "Tag-Name (z. B. MOOD)", + "valuePlaceholder": "Tag-Wert", + "deleteConfirm": "Diesen Tag entfernen?" + }, + "playback": { + "volume": "Lautstärke", + "startTime": "Startzeit", + "stopTime": "Endzeit", + "gapless": "Lückenlos" + }, + "fields": { + "title": "Titel", + "artist": "Interpret", + "sortArtist": "Interpret (Sortierung)", + "album": "Album", + "sortAlbum": "Album (Sortierung)", + "albumArtist": "Albuminterpret", + "sortAlbumArtist": "Albuminterpret (Sortierung)", + "year": "Jahr", + "genre": "Genre", + "style": "Stil", + "composer": "Komponist", + "conductor": "Dirigent", + "bpm": "BPM", + "grouping": "Gruppierung", + "publisher": "Label", + "catalogNumber": "Katalognummer", + "releaseType": "Veröffentlichungstyp", + "lyricist": "Texter", + "barcode": "Barcode", + "work": "Werk", + "originalReleaseDate": "Erstveröffentlichung", + "copyright": "Copyright", + "rating": "Bewertung", + "compilation": "Compilation", + "trackNumber": "Track", + "trackTotal": "Tracks gesamt", + "discNumber": "Disc", + "discTotal": "Discs gesamt", + "duration": "Dauer", + "comment": "Kommentar", + "lyrics": "Liedtext", + "codec": "Codec", + "bitrate": "Bitrate", + "sampleRate": "Abtastrate", + "channels": "Kanäle", + "bitsPerSample": "Bittiefe", + "encoder": "Encoder", + "bitDepthValue": "{value} Bit", + "extension": "Format", + "fileSize": "Größe", + "modifiedAt": "Geändert", + "createdAt": "Erstellt", + "picture": "Cover", + "fileName": "Dateiname", + "filePath": "Dateipfad", + "folderPath": "Ordnerpfad", + "dateAdded": "Hinzugefügt am", + "lastPlayed": "Zuletzt gespielt", + "listenedAt": "Gehört am", + "playCount": "Wiedergaben", + "lossless": "Verlustfrei" + }, + "history": { + "title": "Änderungsverlauf", + "titleForSong": "Verlauf — {songTitle}", + "empty": "Noch keine Änderungen erfasst", + "unknownSong": "Unbekannt", + "revert": "Rückgängig machen", + "searchPlaceholder": "Nach Songtitel oder Interpret filtern...", + "viewHistory": "Änderungsverlauf anzeigen", + "rescanSong": "Songdatei neu einlesen", + "revertSelected": "{count, plural, one {# Änderung} other {# Änderungen}} rückgängig machen", + "clearSelection": "Auswahl aufheben", + "emptyValue": "—" + }, + "columns": { + "toggleColumns": "Spalten", + "genericTags": "Allgemeine Tags", + "customTags": "Eigene Tags" + }, + "previewCard": { + "play": "Abspielen", + "pause": "Pause", + "editCover": "Cover bearbeiten", + "downloadCover": "Cover herunterladen", + "lossless": "Verlustfrei" + }, + "musicbrainzLookup": { + "title": "MusicBrainz — {songTitle}", + "tooltip": "Metadaten bei MusicBrainz nachschlagen", + "search": "Suchen", + "back": "Zurück", + "score": "Übereinstimmung: {score} %", + "noResults": "Keine Aufnahmen gefunden", + "noSearchFields": "Füge ein Feld für die Suche hinzu", + "loadMore": "Mehr laden", + "resultsCount": "{shown} von {total} Ergebnissen", + "field": "Feld", + "currentValue": "Aktuell", + "mbValue": "MusicBrainz", + "applySelected": "Auswahl übernehmen", + "noFields": "Diese Veröffentlichung liefert keine Metadatenfelder", + "coverArt": "Cover", + "currentCover": "Aktuell", + "mbCover": "MusicBrainz", + "noCover": "Kein Cover", + "replaceCover": "Cover ersetzen", + "mbid": "MBID", + "mbidPlaceholder": "MBID einer Aufnahme, Veröffentlichung oder eines Interpreten", + "matchMode": "Treffer", + "matchModeOption": { + "all": "Alle Felder", + "any": "Beliebiges Feld" + }, + "searchFields": "Felder" + }, + "musicbrainz": { + "fetchSuccess": "Cover von MusicBrainz aktualisiert", + "fetchError": "Das Cover konnte nicht geladen werden", + "unknownError": "Ein unbekannter Fehler ist aufgetreten", + "fetchMusicBrainz": "Cover von MusicBrainz holen", + "fetchMusicBrainzConfirmTitle": "Cover von MusicBrainz holen?", + "fetchMusicBrainzConfirmDescription": "Sucht bei MusicBrainz nach einem Albumcover und ersetzt das aktuelle Cover, falls eines gefunden wird. Das lässt sich im Verlauf rückgängig machen.", + "fetchMusicBrainzConfirmAction": "Cover holen" + }, + "stats": { + "title": "Statistik" + }, + "formats": { + "mp3": "MPEG Audio Layer 3", + "flac": "Free Lossless Audio Codec", + "wav": "Waveform Audio", + "m4a": "MPEG-4 Audio", + "m4b": "MPEG-4-Hörbuch", + "ogg": "Ogg Vorbis", + "aac": "Advanced Audio Coding", + "unknown": "Audiodatei" + }, + "login": { + "title": "Anmelden", + "description": "Gib deine Zugangsdaten ein, um auf dein Konto zuzugreifen", + "username": "Benutzername", + "usernamePlaceholder": "Benutzername eingeben", + "password": "Passwort", + "passwordPlaceholder": "Passwort eingeben", + "submit": "Anmelden", + "submitting": "Anmeldung läuft...", + "invalidCredentials": "Benutzername oder Passwort ist falsch", + "genericError": "Ein Fehler ist aufgetreten. Bitte versuche es erneut." + }, + "welcome": { + "title": "Willkommen bei Tagr", + "description": "Tagr muss deine Musikordner einmal scannen, um eine lokale Datenbank aufzubauen. Dadurch geht das Durchsuchen deiner Bibliothek schnell und flüssig. Dabei verlassen keine Daten deinen Rechner.", + "timing": "Das kann je nach Größe deiner Bibliothek einige Minuten dauern.", + "scanButton": "Bibliothek scannen", + "scanning": "Deine Bibliothek wird gescannt..." + }, + "version": { + "updateAvailable": "Version {version} ist verfügbar", + "viewRelease": "Release ansehen", + "dismiss": "Schließen" + }, + "savedFilters": { + "title": "Filter", + "save": "Speichern", + "saveCurrentFilters": "Aktuelle Filter speichern", + "filterName": "Filtername", + "noSavedFilters": "Keine gespeicherten Filter", + "deleteFilter": "Filter löschen", + "loadFilter": "Filter laden" + }, + "share": { + "tooltip": "Song teilen", + "title": "Song teilen", + "description": "Erzeugt einen zeitlich begrenzten Link nur zum Anhören. Empfänger können den Song abspielen, aber keine Daten ändern oder löschen.", + "expiration": "Link läuft ab in", + "generate": "Link erzeugen", + "generating": "Wird erzeugt...", + "copyLink": "Link kopieren", + "copied": "Kopiert!", + "linkReady": "Link zum Teilen ist bereit", + "expiresAt": "Läuft ab: {date}", + "pageTitle": "Geteilter Song — {title}", + "expired": "Dieser Link ist abgelaufen", + "notFound": "Link nicht gefunden", + "errorDescription": "Der Link ist möglicherweise abgelaufen oder wurde entfernt.", + "sharedVia": "Geteilt über", + "of": "von", + "metadata": { + "fallbackTitle": "Tagr — Geteilter Song", + "untitled": "Ohne Titel", + "unknownArtist": "Unbekannter Interpret", + "title": "{title} — {artist} | Tagr", + "description": "Hör dir {title} von {artist} an", + "descriptionWithAlbum": "Hör dir {title} von {artist} aus {album} an", + "ogTitle": "{title} — {artist}" + } + }, + "time": { + "fiveMinutes": "5 Minuten", + "fifteenMinutes": "15 Minuten", + "oneHour": "1 Stunde", + "sixHours": "6 Stunden", + "twentyFourHours": "24 Stunden", + "sevenDays": "7 Tage", + "thirtyDays": "30 Tage" + }, + "starPrompt": { + "title": "Gefällt dir Tagr?", + "description": "Wenn dir Tagr nützt, gib dem Projekt doch einen Stern auf GitHub. Das hilft anderen, es zu finden! Keine Sorge, wir fragen nicht noch einmal.", + "starOnGithub": "Stern auf GitHub geben", + "maybeLater": "Nein danke" + }, + "api": { + "folders": { + "noFoldersConfigured": "Keine Musikordner konfiguriert. Setze die Umgebungsvariable MUSIC_FOLDERS.", + "pathNotDirectory": "Der Pfad ist kein Verzeichnis", + "unknownError": "Unbekannter Fehler beim Lesen des Ordners" + } + }, + "users": { + "title": "Benutzer verwalten", + "description": "Lege die Benutzer an, die auf Tagr zugreifen dürfen, und bearbeite oder entferne sie.", + "createUser": "Benutzer anlegen", + "editUser": "Benutzer bearbeiten", + "deleteUser": "Benutzer löschen", + "deleteConfirm": "Möchtest du den Benutzer \"{username}\" wirklich löschen?", + "username": "Benutzername", + "password": "Passwort", + "passwordHint": "Leer lassen, um das aktuelle Passwort zu behalten", + "role": "Rolle", + "roles": { + "admin": "Admin", + "tagger": "Tagger", + "listener": "Zuhörer" + }, + "roleDescriptions": { + "tagger": "Darf durchsuchen, abspielen, Metadaten-Tags bearbeiten und die Bibliothek neu scannen", + "listener": "Darf Musik nur durchsuchen und abspielen" + }, + "noUsers": "Noch keine Benutzer", + "noUsersDescription": "Lege deinen ersten Benutzer an, um loszulegen", + "userCreated": "Benutzer angelegt", + "userUpdated": "Benutzer aktualisiert", + "userDeleted": "Benutzer gelöscht", + "cancel": "Abbrechen", + "save": "Speichern" + }, + "smartPlaylists": { + "title": "Intelligente Playlists", + "newPlaylist": "Neue intelligente Playlist", + "publicHeader": "Öffentliche intelligente Playlists", + "publicBadge": "Öffentlich", + "empty": "Noch keine intelligenten Playlists", + "confirmDelete": "Intelligente Playlist \"{name}\" löschen?", + "copy": "Kopie", + "viewing": { + "label": "Intelligente Playlist" + }, + "create": { + "title": "Intelligente Playlist erstellen", + "description": "Lege Regeln fest, nach denen Songs automatisch aufgenommen werden, wenn ein Feld passt.", + "name": "Name", + "namePlaceholder": "Meine intelligente Playlist", + "isPublic": "Playlist für andere Benutzer sichtbar machen", + "matchLabel": "Treffer", + "matchAll": "Alle Regeln erfüllen (UND)", + "matchAny": "Eine Regel erfüllen (ODER)", + "rulesLabel": "Regeln", + "addRule": "Regel hinzufügen", + "standardFields": "Song-Felder", + "customFields": "Eigene Metadaten", + "valuePlaceholder": "Wert" + }, + "edit": { + "title": "Intelligente Playlist bearbeiten" + }, + "operators": { + "equals": "ist gleich", + "notEquals": "ist ungleich", + "contains": "enthält", + "notContains": "enthält nicht", + "startsWith": "beginnt mit", + "endsWith": "endet auf", + "isEmpty": "ist leer", + "isNotEmpty": "ist nicht leer", + "gt": "größer als", + "gte": "größer oder gleich", + "lt": "kleiner als", + "lte": "kleiner oder gleich", + "before": "vor", + "after": "nach", + "on": "am", + "isTrue": "ist wahr", + "isFalse": "ist falsch" + } + }, + "bulkEdit": { + "selectAllButton": "Alle Songs auswählen", + "contextMenu": { + "selectSong": "Song auswählen", + "selectAllFolder": "Ganzen Ordner auswählen", + "selectAllPlaylist": "Ganze intelligente Playlist auswählen", + "selectAllRecent": "Alles zuletzt Gespielte auswählen", + "clearSelection": "Auswahl aufheben", + "setCover": "Cover für die Auswahl festlegen" + }, + "actionBar": { + "selectedCount": "{count, plural, one {# Song ausgewählt} other {# Songs ausgewählt}}", + "wholeFolder": "(ganzer Ordner)", + "wholePlaylist": "(ganze Playlist)", + "edit": "Bearbeiten", + "setCover": "Cover festlegen", + "fetchCovers": "Cover von MusicBrainz holen", + "cancel": "Auswahl abbrechen" + }, + "edit": { + "title": "Mehrfachbearbeitung", + "description": "Geänderte Felder werden auf alle ausgewählten Songs angewendet. Unveränderte Felder bleiben, wie sie sind.", + "variousPlaceholder": "(verschiedene)", + "partialSampleNote": "Zeigt gemeinsame Werte aus {loaded} geladenen von {total} Songs. Unveränderte Felder bleiben bei allen ausgewählten Songs, wie sie sind.", + "noFieldsTouched": "Ändere mindestens ein Feld, bevor du fortfährst.", + "next": "Weiter", + "sections": { + "music": "Musik", + "track": "Track", + "misc": "Sonstiges", + "custom": "Eigene Tags" + }, + "fieldsMenu": "Felder", + "addCustomKey": "Eigenen Tag hinzufügen", + "customKeyPlaceholder": "Neuer Tag-Name (z. B. SOURCEMEDIA)" + }, + "cover": { + "title": "Cover von MusicBrainz holen", + "description": "Für jeden ausgewählten Song wird die passende MusicBrainz-Veröffentlichung gesucht und deren Cover geschrieben. Dabei geht höchstens eine Anfrage pro Sekunde raus.", + "estimate": "Geschätzte Dauer: ~{duration} für {count, plural, one {# Song} other {# Songs}}.", + "warning": "Vorhandene Cover werden überschrieben, lassen sich aber bei Bedarf aus dem Verlauf wiederherstellen." + }, + "setCover": { + "title": "Cover festlegen", + "description": "Wähle ein Bild aus, es wird als Cover {count, plural, one {des ausgewählten Songs} other {aller # ausgewählten Songs}} geschrieben.", + "dropHint": "Zieh ein Bild hierher oder nutze die Schaltfläche unten.", + "upload": "Bild hochladen", + "replaceFile": "Bild ersetzen", + "removeFile": "Bild entfernen", + "selectedAnnounce": "Ausgewähltes Bild: {name}", + "errorNotImage": "Diese Datei ist kein Bild.", + "errorTooLarge": "Das Bild ist zu groß. Die maximale Größe ist {size}.", + "applyNote": "Dieses Bild ersetzt das Cover von {count, plural, one {# Song} other {# Songs}}." + }, + "progress": { + "label": "Verarbeitet {completed} / {total}" + }, + "confirm": { + "affecting": "{count, plural, one {# Song betroffen} other {# Songs betroffen}}", + "applyToCount": "Auf {count, plural, one {# Song} other {# Songs}} anwenden", + "context": { + "explicit": "{count, plural, one {# Song ausgewählt} other {# Songs ausgewählt}}", + "folder": "Ordner {name}", + "playlist": "Intelligente Playlist {name}" + } + }, + "result": { + "success": "{count, plural, one {# Song aktualisiert} other {# Songs aktualisiert}}", + "partial": "{ok} aktualisiert, {failed} fehlgeschlagen", + "allFailed": "Alle {count} Songs sind fehlgeschlagen", + "viewDetails": "Details" + } + }, + "bulkSummary": { + "titleEdit": "Zusammenfassung der Mehrfachbearbeitung", + "titleCover": "Zusammenfassung des Cover-Abrufs", + "totalFiles": "{count, plural, one {# Song verarbeitet} other {# Songs verarbeitet}}", + "titleSetCover": "Zusammenfassung der Cover-Aktualisierung" + }, + "settings": { + "title": "Einstellungen", + "sections": { + "users": "Benutzer", + "integrations": "Integrationen von Drittanbietern" + } + }, + "scrobbling": { + "listenbrainz": "ListenBrainz", + "description": "Sende die Songs, die du in Tagr abspielst, an dein ListenBrainz-Profil.", + "enable": "Gespielte Songs scrobbeln", + "token": "Benutzer-Token", + "tokenPlaceholder": "ListenBrainz-Token einfügen", + "tokenStored": "Ein Token ist bereits gespeichert — gib einen neuen ein, um ihn zu ersetzen", + "tokenHelp": "Kopiere ihn aus deinen ListenBrainz-Einstellungen.", + "apiRoot": "API-Root (optional)", + "save": "Prüfen und speichern", + "saved": "Scrobbling-Einstellungen gespeichert", + "connectedAs": "Verbunden als {username}", + "disconnect": "Trennen", + "disconnected": "ListenBrainz getrennt", + "pending": "{count} Wiedergaben warten auf den Versand" + }, + "listens": { + "title": "Zuletzt gespielt", + "empty": "Noch nichts abgespielt" + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 61eafcd1..77ec1a73 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { SessionProvider } from 'next-auth/react' import { NextIntlClientProvider } from 'next-intl' +import { getLocale } from 'next-intl/server' import { Geist, Geist_Mono } from 'next/font/google' import { QueryProvider } from '@/components/providers/query-provider' import { ServiceWorkerRegister } from '@/components/pwa/service-worker-register' @@ -48,13 +49,15 @@ export const metadata: Metadata = { }, } -export default function RootLayout({ +export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + const locale = await getLocale() + return ( - + diff --git a/src/i18n/locale.test.ts b/src/i18n/locale.test.ts new file mode 100644 index 00000000..0243001e --- /dev/null +++ b/src/i18n/locale.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from 'fs' +import path from 'path' +import { describe, expect, it } from 'vitest' +import { defaultLocale, locales, negotiateLocale } from './locale' + +describe('negotiateLocale', () => { + it('falls back to the default locale without a header', () => { + expect(negotiateLocale(null)).toBe(defaultLocale) + expect(negotiateLocale('')).toBe(defaultLocale) + }) + + it('matches a regional tag to its base language', () => { + expect(negotiateLocale('de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7')).toBe('de') + expect(negotiateLocale('de-AT')).toBe('de') + }) + + it('respects quality values', () => { + expect(negotiateLocale('en;q=0.5, de;q=0.9')).toBe('de') + expect(negotiateLocale('fr-FR,fr;q=0.9,de;q=0.8,en;q=0.7')).toBe('de') + }) + + it('falls back when no supported language is requested', () => { + expect(negotiateLocale('fr-FR,fr;q=0.9')).toBe(defaultLocale) + expect(negotiateLocale('*')).toBe(defaultLocale) + expect(negotiateLocale('de;q=0')).toBe(defaultLocale) + }) +}) + +type Messages = { [key: string]: string | Messages } + +function flatten(messages: Messages, prefix = ''): Record { + return Object.entries(messages).reduce>((all, [key, value]) => { + const name = prefix + key + return typeof value === 'string' ? { ...all, [name]: value } : { ...all, ...flatten(value, `${name}.`) } + }, {}) +} + +function readMessages(locale: string): Record { + return flatten(JSON.parse(readFileSync(path.join(process.cwd(), 'messages', `${locale}.json`), 'utf-8'))) +} + +// Placeholders like {count} or {songTitle}; the words inside plural branches ("one {# file}") are not placeholders +function placeholders(message: string): string[] { + const withoutBranches = message.replace(/(=\d+|zero|one|two|few|many|other)\s*\{[^{}]*\}/g, '$1') + return [...new Set([...withoutBranches.matchAll(/\{(\w+)[,}]/g)].map(match => match[1]))].sort() +} + +describe('translations', () => { + const english = readMessages('en') + + for (const locale of locales.filter(locale => locale !== 'en')) { + it(`${locale}.json has exactly the same keys as en.json`, () => { + expect(Object.keys(readMessages(locale)).sort()).toEqual(Object.keys(english).sort()) + }) + + it(`${locale}.json keeps every placeholder of en.json`, () => { + const translated = readMessages(locale) + for (const [key, message] of Object.entries(english)) { + expect(placeholders(translated[key] ?? ''), key).toEqual(placeholders(message)) + } + }) + } +}) diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts new file mode 100644 index 00000000..38b36bc9 --- /dev/null +++ b/src/i18n/locale.ts @@ -0,0 +1,31 @@ +export const locales = ['en', 'de'] as const + +export type Locale = (typeof locales)[number] + +export const defaultLocale: Locale = 'en' + +function isLocale(value: string): value is Locale { + return (locales as readonly string[]).includes(value) +} + +/** + * Picks the UI language from an `Accept-Language` header, e.g. `de-DE,de;q=0.9,en;q=0.8`. + * Languages are tried in the order of their quality value; a regional tag (`de-AT`) matches its base + * language. Anything unsupported or malformed falls back to the default locale. + */ +export function negotiateLocale(acceptLanguage: string | null | undefined): Locale { + if (!acceptLanguage) return defaultLocale + + const preferred = acceptLanguage + .split(',') + .map((part, index) => { + const [tag, ...params] = part.trim().split(';') + const qParam = params.map(param => param.trim()).find(param => param.startsWith('q=')) + const quality = qParam ? Number(qParam.slice(2)) : 1 + return { language: tag.trim().toLowerCase().split('-')[0], quality, index } + }) + .filter(entry => entry.language && entry.language !== '*' && Number.isFinite(entry.quality) && entry.quality > 0) + .sort((a, b) => b.quality - a.quality || a.index - b.index) + + return preferred.map(entry => entry.language).find(isLocale) ?? defaultLocale +} diff --git a/src/i18n/request.ts b/src/i18n/request.ts index e79a33f6..fc4b02fd 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,7 +1,9 @@ import { getRequestConfig } from 'next-intl/server' +import { headers } from 'next/headers' +import { negotiateLocale } from './locale' export default getRequestConfig(async () => { - const locale = 'en' + const locale = negotiateLocale((await headers()).get('accept-language')) return { locale, From 30a40afc5a4335ee23e0533e9b3bf1c4c3a67faa Mon Sep 17 00:00:00 2001 From: Hellraiser28 <193626466+Hellraiser28@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:18:19 +0200 Subject: [PATCH 2/3] Translate the strings added on main since the German translation German for the Insights settings and notice, the song panel field settings and the hidden custom tags hint. --- messages/de.json | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index a0a1b905..fb2d6708 100644 --- a/messages/de.json +++ b/messages/de.json @@ -126,6 +126,7 @@ "customTags": { "title": "Eigene Tags", "addTag": "Tag hinzufügen", + "allHidden": "Alle eigenen Tags dieses Songs sind ausgeblendet", "key": "Schlüssel", "value": "Wert", "empty": "Keine eigenen Tags", @@ -285,7 +286,8 @@ "description": "Tagr muss deine Musikordner einmal scannen, um eine lokale Datenbank aufzubauen. Dadurch geht das Durchsuchen deiner Bibliothek schnell und flüssig. Dabei verlassen keine Daten deinen Rechner.", "timing": "Das kann je nach Größe deiner Bibliothek einige Minuten dauern.", "scanButton": "Bibliothek scannen", - "scanning": "Deine Bibliothek wird gescannt..." + "scanning": "Deine Bibliothek wird gescannt...", + "insightsNotice": "Tagr kann einmal am Tag anonyme Nutzungsstatistiken (Insights) senden: nur Zahlen, niemals Dateinamen, Pfade oder Benutzernamen. Gesendet wird erst, wenn du zustimmst, und du kannst es in den Einstellungen abschalten." }, "version": { "updateAvailable": "Version {version} ist verfügbar", @@ -510,9 +512,18 @@ "title": "Einstellungen", "sections": { "users": "Benutzer", - "integrations": "Integrationen von Drittanbietern" + "integrations": "Integrationen von Drittanbietern", + "detailPanel": "Song-Panel", + "insights": "Insights" } }, + "detailPanelSettings": { + "title": "Felder im Song-Panel", + "description": "Wähle, welche Felder das Song-Panel anzeigt. Ausgeblendete Felder behalten ihre Werte, sie werden nur nicht aufgelistet.", + "showAll": "Alle anzeigen", + "noCustomTags": "Noch keine eigenen Tags in deiner Bibliothek", + "tooltip": "Sichtbare Felder wählen" + }, "scrobbling": { "listenbrainz": "ListenBrainz", "description": "Sende die Songs, die du in Tagr abspielst, an dein ListenBrainz-Profil.", @@ -532,5 +543,32 @@ "listens": { "title": "Zuletzt gespielt", "empty": "Noch nichts abgespielt" + }, + "insights": { + "title": "Insights", + "description": "Insights sind die anonymen Nutzungsstatistiken von Tagr. Einmal am Tag sendet Tagr einen kleinen Bericht (Version, Größe der Bibliothek, genutzte Funktionen), damit wir wissen, wie viele Leute Tagr nutzen und was sich verbessern lässt. Dateinamen, Pfade, Tags und Benutzernamen sind nie enthalten.", + "enable": "Insights senden (anonyme Nutzungsstatistiken)", + "forcedByEnv": "Durch die Umgebungsvariable TAGR_INSIGHTS deaktiviert.", + "lastSent": "Letzter Bericht gesendet am {date}.", + "neverSent": "Noch kein Bericht gesendet.", + "dashboard": "Die zusammengefassten Ergebnisse sind öffentlich: zum Dashboard.", + "preview": "Genau anzeigen, was gesendet wird", + "previewHelp": "Dieses JSON wird an {endpoint} gesendet:", + "enabledToast": "Insights aktiviert", + "disabledToast": "Insights deaktiviert", + "noticeTitle": "Hilf mit Insights, Tagr zu verbessern", + "noticeBody": "Insights sind anonyme Nutzungsstatistiken: Einmal am Tag kann Tagr einen kleinen Bericht senden, damit wir wissen, wie viele Leute Tagr nutzen und an welchen Funktionen sich die Arbeit lohnt. Bisher wurde nichts gesendet, und gesendet wird nur, wenn du es aktiviert lässt.", + "noticeDetails": "Genauen Bericht anzeigen", + "noticeKeep": "Aktiviert lassen", + "noticeDisable": "Deaktivieren", + "pendingNotice": "Bisher wurde nichts gesendet: Es geht los, sobald ein Admin bestätigt.", + "noticeSentTitle": "Was gesendet wird", + "noticeSent": "Tagr-Version, Betriebssystem und Architektur, Anzahl der Songs und Benutzer, Dateiformate und wie oft Funktionen wie Tag-Bearbeitung, intelligente Playlists oder Scrobbling genutzt werden.", + "noticeNeverTitle": "Was nie gesendet wird", + "noticeNever": "Dateinamen, Pfade, Tags, Benutzernamen, Tokens oder deine IP-Adresse.", + "noticeLater": "Du kannst das jederzeit unter Einstellungen → Insights ändern.", + "sendNow": "Jetzt senden", + "sentToast": "Bericht gesendet", + "sendFailedToast": "Bericht konnte nicht gesendet werden: {error}" } } From dd19699d40b2894634c730c6564fc6423202cff4 Mon Sep 17 00:00:00 2001 From: Hellraiser28 <193626466+Hellraiser28@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:18:19 +0200 Subject: [PATCH 3/3] Add a language setting to the settings dialog A Language section (listener and up) offers Automatic, English and Deutsch, built from the locales in src/i18n/locale.ts. The choice is stored under the 'locale' config key; request.ts uses it when it names a supported locale and otherwise negotiates from Accept-Language as before. Saving refreshes the router so the server renders the new language. --- README.md | 5 +- messages/de.json | 9 ++- messages/en.json | 9 ++- .../components/language-settings.tsx | 56 +++++++++++++++++++ .../settings-modal/settings-modal.tsx | 12 +++- src/features/config/domain.ts | 1 + src/i18n/locale.test.ts | 21 ++++++- src/i18n/locale.ts | 20 ++++++- src/i18n/request.ts | 12 +++- 9 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 src/components/settings-modal/components/language-settings.tsx diff --git a/README.md b/README.md index 54f5d1ee..188cfbcc 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,9 @@ Tagr is fully responsive and works on phones and tablets. The mobile UI adapts t - **Multi-user authentication** — password-protected access with roles (admin, tagger, listener) - **Resizable panels** — drag to resize the three-panel layout to your liking - **Dark theme** by default -- **English and German UI** — the language follows the browser's `Accept-Language` setting and falls back to - English. Translations live in `messages/.json`; a new language needs its file plus an entry in +- **English and German UI** — each user picks the language under Settings → Language; on "Automatic" it follows + the browser's `Accept-Language` setting and falls back to English. Translations live in + `messages/.json`; a new language needs its file plus an entry (and its display name) in `src/i18n/locale.ts` - **Toast notifications** for operation feedback - **URL-based state** — bookmarkable views with folder, song, sort, and filter state preserved in the URL diff --git a/messages/de.json b/messages/de.json index fb2d6708..49c65a0b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -514,7 +514,8 @@ "users": "Benutzer", "integrations": "Integrationen von Drittanbietern", "detailPanel": "Song-Panel", - "insights": "Insights" + "insights": "Insights", + "language": "Sprache" } }, "detailPanelSettings": { @@ -524,6 +525,12 @@ "noCustomTags": "Noch keine eigenen Tags in deiner Bibliothek", "tooltip": "Sichtbare Felder wählen" }, + "languageSettings": { + "title": "Sprache", + "description": "Wähle, in welcher Sprache Tagr angezeigt wird. „Automatisch“ folgt der Sprache deines Browsers.", + "label": "Sprache der Oberfläche", + "automatic": "Automatisch" + }, "scrobbling": { "listenbrainz": "ListenBrainz", "description": "Sende die Songs, die du in Tagr abspielst, an dein ListenBrainz-Profil.", diff --git a/messages/en.json b/messages/en.json index b24e5044..c73674c7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -514,7 +514,8 @@ "users": "Users", "integrations": "Third party integrations", "detailPanel": "Song panel", - "insights": "Insights" + "insights": "Insights", + "language": "Language" } }, "detailPanelSettings": { @@ -524,6 +525,12 @@ "noCustomTags": "No custom tags in your library yet", "tooltip": "Choose visible fields" }, + "languageSettings": { + "title": "Language", + "description": "Choose the language Tagr is shown in. Automatic follows your browser's language.", + "label": "Interface language", + "automatic": "Automatic" + }, "scrobbling": { "listenbrainz": "ListenBrainz", "description": "Send the songs you play in Tagr to your ListenBrainz profile.", diff --git a/src/components/settings-modal/components/language-settings.tsx b/src/components/settings-modal/components/language-settings.tsx new file mode 100644 index 00000000..3aa24b26 --- /dev/null +++ b/src/components/settings-modal/components/language-settings.tsx @@ -0,0 +1,56 @@ +'use client' + +import { toast } from 'sonner' +import { useTranslations } from 'next-intl' +import { useRouter } from 'next/navigation' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useConfig } from '@/features/config/hooks/use-config' +import { useUpdateConfig } from '@/features/config/hooks/use-update-config' +import { AUTOMATIC_LOCALE, localeNames, locales } from '@/i18n/locale' + +const parse = (value: string | null) => value || AUTOMATIC_LOCALE + +export function LanguageSettings() { + const t = useTranslations('languageSettings') + const router = useRouter() + const { data: selected } = useConfig({ key: 'locale', parser: parse, defaultData: AUTOMATIC_LOCALE }) + const { mutate, isPending } = useUpdateConfig({ parser: parse }) + + const handleChange = (value: string) => { + mutate( + { key: 'locale', value }, + { + // The language is picked on the server, so the page has to be rendered again + onSuccess: () => router.refresh(), + onError: error => toast.error(error.message) + } + ) + } + + return ( +
+
+

{t('title')}

+

{t('description')}

+
+ +
+ + +
+
+ ) +} diff --git a/src/components/settings-modal/settings-modal.tsx b/src/components/settings-modal/settings-modal.tsx index 24f5cd8c..b3652ed7 100644 --- a/src/components/settings-modal/settings-modal.tsx +++ b/src/components/settings-modal/settings-modal.tsx @@ -1,6 +1,14 @@ 'use client' -import { ChartNoAxesColumnIcon, PanelRightIcon, PlugIcon, SettingsIcon, UsersIcon, type LucideIcon } from 'lucide-react' +import { + ChartNoAxesColumnIcon, + LanguagesIcon, + PanelRightIcon, + PlugIcon, + SettingsIcon, + UsersIcon, + type LucideIcon +} from 'lucide-react' import { useState } from 'react' import { useSession } from 'next-auth/react' import { useTranslations } from 'next-intl' @@ -12,6 +20,7 @@ import { hasMinimumRole } from '@/features/users/lib/hasMinimumRole' import { useBreakpoint } from '@/hooks/use-breakpoint' import { DetailPanelSettings } from './components/detail-panel-settings' import { InsightsSettings } from './components/insights-settings' +import { LanguageSettings } from './components/language-settings' import { ListenBrainzSettings } from './components/listenbrainz-settings' import { UsersSettings } from './components/users-settings/users-settings' @@ -39,6 +48,7 @@ const SETTINGS_SECTIONS = [ minimumRole: 'listener', Content: DetailPanelSettings }, + { id: 'language', labelKey: 'language', icon: LanguagesIcon, minimumRole: 'listener', Content: LanguageSettings }, { id: 'insights', labelKey: 'insights', icon: ChartNoAxesColumnIcon, minimumRole: 'admin', Content: InsightsSettings } ] as const satisfies readonly SettingsSectionDefinition[] diff --git a/src/features/config/domain.ts b/src/features/config/domain.ts index d1adf6db..946d0f77 100644 --- a/src/features/config/domain.ts +++ b/src/features/config/domain.ts @@ -11,6 +11,7 @@ export type ConfigKey = | 'musicbrainzSearchFields' | 'bulkEditFields' | 'detailPanelFields' + | 'locale' /** Which inputs the MusicBrainz lookup form shows. */ export type MusicBrainzSearchFieldsState = Record diff --git a/src/i18n/locale.test.ts b/src/i18n/locale.test.ts index 0243001e..bb4fc804 100644 --- a/src/i18n/locale.test.ts +++ b/src/i18n/locale.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'fs' import path from 'path' import { describe, expect, it } from 'vitest' -import { defaultLocale, locales, negotiateLocale } from './locale' +import { AUTOMATIC_LOCALE, defaultLocale, localeNames, locales, negotiateLocale, resolveLocale } from './locale' describe('negotiateLocale', () => { it('falls back to the default locale without a header', () => { @@ -26,6 +26,25 @@ describe('negotiateLocale', () => { }) }) +describe('resolveLocale', () => { + it('prefers the language the user saved over the browser', () => { + expect(resolveLocale('de', 'en-US,en;q=0.9')).toBe('de') + expect(resolveLocale('en', 'de-DE,de;q=0.9')).toBe('en') + }) + + it('follows the browser when the setting is automatic, empty or no longer supported', () => { + expect(resolveLocale(AUTOMATIC_LOCALE, 'de-DE')).toBe('de') + expect(resolveLocale(null, 'de-DE')).toBe('de') + expect(resolveLocale('', 'de-DE')).toBe('de') + expect(resolveLocale('fr', 'de-DE')).toBe('de') + expect(resolveLocale(AUTOMATIC_LOCALE, null)).toBe(defaultLocale) + }) + + it('has a display name for every locale', () => { + expect(Object.keys(localeNames).sort()).toEqual([...locales].sort()) + }) +}) + type Messages = { [key: string]: string | Messages } function flatten(messages: Messages, prefix = ''): Record { diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts index 38b36bc9..e8e44df6 100644 --- a/src/i18n/locale.ts +++ b/src/i18n/locale.ts @@ -4,10 +4,28 @@ export type Locale = (typeof locales)[number] export const defaultLocale: Locale = 'en' -function isLocale(value: string): value is Locale { +/** Each locale's name in its own language, as offered in the language setting. */ +export const localeNames: Record = { + en: 'English', + de: 'Deutsch' +} + +/** Stored under the `locale` config key when the user lets the browser decide. */ +export const AUTOMATIC_LOCALE = 'auto' + +export function isLocale(value: string): value is Locale { return (locales as readonly string[]).includes(value) } +/** + * The UI language for a request: the user's saved choice when it names a supported locale, otherwise + * the language the browser asks for. "auto", an empty value or a locale that was removed all fall + * through to the browser. + */ +export function resolveLocale(saved: string | null | undefined, acceptLanguage: string | null | undefined): Locale { + return saved && isLocale(saved) ? saved : negotiateLocale(acceptLanguage) +} + /** * Picks the UI language from an `Accept-Language` header, e.g. `de-DE,de;q=0.9,en;q=0.8`. * Languages are tried in the order of their quality value; a regional tag (`de-AT`) matches its base diff --git a/src/i18n/request.ts b/src/i18n/request.ts index fc4b02fd..1c253fb0 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,9 +1,17 @@ import { getRequestConfig } from 'next-intl/server' import { headers } from 'next/headers' -import { negotiateLocale } from './locale' +import { auth } from '@/auth' +import { getConfigValue } from '@/features/config/config.repository' +import { resolveLocale } from './locale' + +async function getSavedLocale(): Promise { + const session = await auth() + return session?.user?.id ? getConfigValue(session.user.id, 'locale') : null +} export default getRequestConfig(async () => { - const locale = negotiateLocale((await headers()).get('accept-language')) + const acceptLanguage = (await headers()).get('accept-language') + const locale = resolveLocale(await getSavedLocale(), acceptLanguage) return { locale,