diff --git a/accounts/views.py b/accounts/views.py index 519c073..30878ff 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -97,7 +97,7 @@ def lastfm_connect(request): return render(request, 'accounts/settings.html', { 'profile': request.user.profile, 'has_lastfm': request.user.profile.has_lastfm(), - 'lastfm_error': f"Could not connect to Last.fm: {exc}", + 'lastfm_error': f"Verbindung zu Last.fm fehlgeschlagen: {exc}", }) diff --git a/diora/settings.py b/diora/settings.py index ffbe165..c4d3f6a 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -89,7 +89,7 @@ AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, ] -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = 'de' TIME_ZONE = 'UTC' USE_I18N = True USE_TZ = True diff --git a/radio/tests.py b/radio/tests.py index e8c52fd..204d77c 100644 --- a/radio/tests.py +++ b/radio/tests.py @@ -23,7 +23,7 @@ class PlayerPagePodcastsTests(TestCase): def test_logged_out_page_asks_to_log_in(self): html = self.client.get('/').content.decode() - self.assertIn('to subscribe to podcasts.', html) + self.assertIn('um Podcasts zu abonnieren.', html) self.assertNotIn('dpw-loading', html) def test_podcast_ui_files_are_there(self): diff --git a/radio/views.py b/radio/views.py index 408297e..c3a7d90 100644 --- a/radio/views.py +++ b/radio/views.py @@ -365,15 +365,16 @@ def stop_play(request): # --------------------------------------------------------------------------- def _time_context_label(hour, is_weekend): - prefix = 'weekend' if is_weekend else 'weekday' + # Shown as "Was du {label} oft hörst:". + prefix = 'am Wochenende' if is_weekend else 'unter der Woche' if 5 <= hour <= 11: - period = 'morning' + period = 'morgens' elif 12 <= hour <= 16: - period = 'afternoon' + period = 'nachmittags' elif 17 <= hour <= 21: - period = 'evening' + period = 'abends' else: - period = 'night' + period = 'nachts' return f'{prefix} {period}' diff --git a/static/js/app.js b/static/js/app.js index 822d78b..6016b1b 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -130,6 +130,24 @@ function customConfirm(message, { danger = false } = {}) { return _showModal({ message, showCancel: true, danger }); } +// The server answers with short English error codes (an API contract, tests and +// other clients rely on them); what the page shows of them is German. +const _GERMAN_ERRORS = { + 'no stations found in file': 'In der Datei wurden keine Sender gefunden.', + 'file must be .m3u or .m3u8': 'Die Datei muss eine .m3u- oder .m3u8-Datei sein.', + 'no file uploaded': 'Keine Datei hochgeladen.', + 'only jpeg, png, or webp allowed': 'Nur JPEG, PNG oder WebP erlaubt.', + 'file too large (max 50 MB)': 'Datei zu groß (höchstens 50 MB).', + 'file too large (max 5 MB)': 'Datei zu groß (höchstens 5 MB).', + 'authentication required': 'Bitte anmelden.', + 'not found': 'Nicht gefunden.', + 'invalid JSON': 'Ungültige Anfrage.', + 'upstream unreachable': 'Der Server dahinter ist nicht erreichbar.', +}; +function _germanError(code) { + return _GERMAN_ERRORS[code] || code; +} + function customPrompt(message, defaultValue = '') { return _showModal({ message, showInput: true, inputValue: defaultValue, showCancel: true }); } @@ -162,7 +180,7 @@ function playStation(url, name, stationId) { $('now-playing-station').textContent = name; $('now-playing-track').textContent = ''; $('play-stop-btn').style.display = ''; - $('play-stop-btn').textContent = '⏹ Stop'; + $('play-stop-btn').textContent = '⏹ Stopp'; $('play-stop-btn').classList.add('playing'); $('save-station-btn').style.display = ''; @@ -200,7 +218,7 @@ function stopPlayback(clearStation = true) { sseSource = null; } - $('play-stop-btn').textContent = '▶ Play'; + $('play-stop-btn').textContent = '▶ Abspielen'; $('play-stop-btn').classList.remove('playing'); $('save-station-btn').style.display = 'none'; $('creamfresh-vote-up-btn').style.display = 'none'; @@ -215,7 +233,7 @@ function stopPlayback(clearStation = true) { if (clearStation) { currentStation = null; currentTrack = ''; - stationEl.textContent = '— no station —'; + stationEl.textContent = '— kein Sender —'; trackEl.textContent = ''; $('play-stop-btn').style.display = 'none'; } @@ -297,11 +315,11 @@ async function loadRecommendations() { const res = await fetch('/radio/recommendations/'); const data = await res.json(); if (!data.recommendations.length) { - container.innerHTML = '
Play more stations to get recommendations.
'; + container.innerHTML = 'Hör mehr Sender, dann gibt es hier Empfehlungen.
'; return; } const label = data.context; - let html = `Based on your ${label} listening:
Was du ${label} oft hörst:
No show notes available.
'; + if (!html) return 'Keine Shownotes vorhanden.
'; const div = document.createElement('div'); div.innerHTML = html; div.querySelectorAll('script, iframe, object, embed, style').forEach(el => el.remove()); @@ -1384,7 +1402,7 @@ async function importM3U(input) { const file = input.files[0]; if (!file) return; const status = document.getElementById('import-status'); - status.textContent = 'Importing…'; + status.textContent = 'Importiere …'; const form = new FormData(); form.append('file', file); @@ -1394,13 +1412,13 @@ async function importM3U(input) { const res = await fetch('/radio/import/', { method: 'POST', body: form }); const data = await res.json(); if (data.ok) { - status.textContent = `✓ ${data.added} added, ${data.skipped} already saved`; + status.textContent = `✓ ${data.added} hinzugefügt, ${data.skipped} schon gespeichert`; if (data.added > 0) location.reload(); } else { - status.textContent = `Error: ${data.error}`; + status.textContent = `Fehler: ${_germanError(data.error)}`; } } catch (e) { - status.textContent = 'Upload failed'; + status.textContent = 'Hochladen fehlgeschlagen'; } input.value = ''; } @@ -1557,11 +1575,11 @@ async function uploadBackground(file) { if (!file) return; const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { - await customAlert('Only JPEG, PNG, or WebP images are allowed.'); + await customAlert('Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.'); return; } if (file.size > DIORA_CONFIG.bgMaxBytes) { - await customAlert(`Image must be ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB or smaller.`); + await customAlert(`Das Bild darf höchstens ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB groß sein.`); return; } @@ -1626,7 +1644,7 @@ async function parseEpub(arrayBuffer) { const containerXml = await zip.file('META-INF/container.xml').async('text'); const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); const rootfileEl = containerDoc.querySelector('rootfile'); - if (!rootfileEl) throw new Error('No rootfile in container.xml'); + if (!rootfileEl) throw new Error('Ungültige EPUB-Datei (container.xml ohne rootfile)'); const opfPath = rootfileEl.getAttribute('full-path'); const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''; @@ -1634,8 +1652,8 @@ async function parseEpub(arrayBuffer) { const opfText = await zip.file(opfPath).async('text'); const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); - const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unknown Title'; - const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unknown Author'; + const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unbekannter Titel'; + const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unbekannter Autor'; // 3. Build manifest: id → {href, mediaType, properties} const manifest = {}; @@ -2311,33 +2329,33 @@ async function loadBookList() { if (!IS_AUTHENTICATED) return; const listEl = $('book-list'); if (!listEl) return; - listEl.innerHTML = 'Loading books…
'; + listEl.innerHTML = 'Bücher werden geladen …
'; try { - listEl.innerHTML = 'Fetching book list from server…
'; + listEl.innerHTML = 'Bücherliste wird vom Server geholt …
'; const res = await fetch('/books/', {cache: 'no-store'}); if (!res.ok) { - listEl.innerHTML = `Server error ${res.status} loading books.
`; + listEl.innerHTML = `Serverfehler ${res.status} beim Laden der Bücher.
`; return; } const books = await res.json(); _evictBookCache(books); // fire-and-forget if (!Array.isArray(books)) { - listEl.innerHTML = `Unexpected response from server (not an array).
`; + listEl.innerHTML = `Unerwartete Antwort vom Server.
`; return; } if (!books.length) { - listEl.innerHTML = 'No books yet. Drop an .epub or .pdf above.
'; + listEl.innerHTML = 'Noch keine Bücher. Zieh oben eine .epub- oder .pdf-Datei hinein.
'; return; } - listEl.innerHTML = `Found ${books.length} book(s) on server. Decrypting…
`; + listEl.innerHTML = `${books.length === 1 ? '1 Buch' : books.length + ' Bücher'} auf dem Server. Entschlüssele …
`; let key; try { key = await getOrCreateEncKey(); } catch (e) { - listEl.innerHTML = `Encryption not available: ${e.message}. Make sure you are on HTTPS.
`; + listEl.innerHTML = `Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.
`; return; } @@ -2349,8 +2367,8 @@ async function loadBookList() { bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || ''}; decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { - bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; - decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; + decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } } // If local cache is further ahead than the server, push it and use it for display. @@ -2398,7 +2416,7 @@ async function loadBookList() { notice.textContent = 'Offline — gespeicherte Bücher. Fortschritt wird beim nächsten Öffnen synchronisiert.'; listEl.prepend(notice); } else { - if (listEl) listEl.innerHTML = `Error loading books: ${e.message}
`; + if (listEl) listEl.innerHTML = `Fehler beim Laden der Bücher: ${e.message}
`; } } } @@ -2488,7 +2506,7 @@ function repairBook(bookId) { meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv, }), }); - if (!(await res.json()).ok) throw new Error('Server error'); + if (!(await res.json()).ok) throw new Error('Serverfehler'); await _evictCachedBook(bookId); _brokenBooks.delete(bookId); if (btn) btn.remove(); @@ -2521,7 +2539,7 @@ async function toggleBookRead(bookId, currentlyRead) { body: JSON.stringify({is_read: !currentlyRead}), }); const data = await res.json(); - if (!data.ok) throw new Error('Server error'); + if (!data.ok) throw new Error('Serverfehler'); const book = _lastBookListData.find(b => b.id === bookId); if (book) book.is_read = data.is_read; renderBookList(_lastBookListData); @@ -2544,19 +2562,19 @@ function _recentlyReadBooks(books) { function _renderBookItemHtml(b) { const pct = Math.round((b.scroll_fraction || 0) * 100); - const keyWarning = b.keyOk === false ? '⚠ wrong key' : ''; + const keyWarning = b.keyOk === false ? '⚠ falscher Schlüssel' : ''; const broken = _brokenBooks.has(b.id); return `- Connected as {{ profile.lastfm_username }} + Verbunden als {{ profile.lastfm_username }}
- Connect your Last.fm account to automatically scrobble the tracks you listen to. + Verbinde dein Last.fm-Konto, damit gehörte Titel automatisch gescrobbelt werden.
- Connect Last.fm + Last.fm verbinden {% endif %}Encrypted background is set. (Preview not available — decrypted in browser on main page.)
+Ein verschlüsselter Hintergrund ist gesetzt. (Keine Vorschau – er wird erst auf der Hauptseite im Browser entschlüsselt.)
-Upload a new image to replace it:
+Neues Bild hochladen, um ihn zu ersetzen:
{% elif request.user.profile.background_image_data %}
-
+
Upload a new image to replace it (will be stored encrypted):
+Neues Bild hochladen, um ihn zu ersetzen (wird verschlüsselt gespeichert):
{% else %} -Upload a custom background image (JPG, PNG or WebP, max 5 MB). Stored end-to-end encrypted.
+Eigenes Hintergrundbild hochladen (JPG, PNG oder WebP, höchstens 5 MB). Es wird Ende-zu-Ende-verschlüsselt gespeichert.
{% endif %}Logged in as {{ request.user.username }}
+Angemeldet als {{ request.user.username }}