Oberfläche eingedeutscht: Radio, Bücher/Reader, Fokus, Konto, Anmeldung; sw v71
- app.js, Templates, Stream-Fenster, Manifest: alle sichtbaren Texte deutsch (Eingabefenster "Abbrechen", Markierungs-Export, Lesezeichen, Reader-Einstellungen …) - LANGUAGE_CODE 'de': Formular-, Passwort- und Validierungsmeldungen von Django deutsch - Empfehlungen: "Was du unter der Woche abends oft hörst:" - Fehlercodes der API bleiben englisch (Vertrag für Clients und Tests); was die Seite davon zeigt, übersetzt _germanError() Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f12451f30e
commit
c522b22cd9
13 changed files with 284 additions and 259 deletions
|
|
@ -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}",
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
|
||||
|
||||
|
|
|
|||
299
static/js/app.js
299
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 = '<p class="muted">Play more stations to get recommendations.</p>';
|
||||
container.innerHTML = '<p class="muted">Hör mehr Sender, dann gibt es hier Empfehlungen.</p>';
|
||||
return;
|
||||
}
|
||||
const label = data.context;
|
||||
let html = `<p class="recommendations-context">Based on your ${label} listening:</p><ul class="recommendations-list">`;
|
||||
let html = `<p class="recommendations-context">Was du ${label} oft hörst:</p><ul class="recommendations-list">`;
|
||||
for (const r of data.recommendations) {
|
||||
html += `<li>
|
||||
<button class="btn btn-sm" onclick="playStation('${escapeAttr(r.station_url)}', '${escapeAttr(r.station_name)}', ${r.saved_id || 'null'})">
|
||||
|
|
@ -431,7 +449,7 @@ function addHistoryRow(stationName, track) {
|
|||
<td>${escapeHtml(stationName)}</td>
|
||||
<td>${escapeHtml(track)}</td>
|
||||
<td></td>
|
||||
<td><button class="btn-delete-history" onclick="deleteHistoryEntry(null, this)" title="Remove">✕</button></td>
|
||||
<td><button class="btn-delete-history" onclick="deleteHistoryEntry(null, this)" title="Entfernen">✕</button></td>
|
||||
`;
|
||||
tbody.insertBefore(tr, tbody.firstChild);
|
||||
}
|
||||
|
|
@ -510,7 +528,7 @@ async function doSearch() {
|
|||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = 'Searching…';
|
||||
statusEl.textContent = 'Suche …';
|
||||
|
||||
try {
|
||||
const url = `https://de1.api.radio-browser.info/json/stations/search?name=${encodeURIComponent(query)}&limit=50&hidebroken=true&order=clickcount&reverse=true`;
|
||||
|
|
@ -519,11 +537,11 @@ async function doSearch() {
|
|||
const stations = await res.json();
|
||||
|
||||
if (!stations.length) {
|
||||
statusEl.textContent = 'No stations found.';
|
||||
statusEl.textContent = 'Keine Sender gefunden.';
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = `${stations.length} result(s)`;
|
||||
statusEl.textContent = stations.length === 1 ? '1 Treffer' : `${stations.length} Treffer`;
|
||||
tableEl.style.display = '';
|
||||
const curated = document.getElementById('curated-lists');
|
||||
if (curated) curated.style.display = 'none';
|
||||
|
|
@ -551,14 +569,14 @@ async function doSearch() {
|
|||
tags: st.tags || '',
|
||||
favicon_url: st.favicon || '',
|
||||
})})'>
|
||||
▶ Play
|
||||
▶ Abspielen
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Search failed: ' + err.message;
|
||||
statusEl.textContent = 'Suche fehlgeschlagen: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -585,7 +603,7 @@ function showStreamAddressResult(url) {
|
|||
const shown = url.split('?')[0];
|
||||
const data = {name, url, bitrate: '', country: '', tags: '', favicon_url: ''};
|
||||
|
||||
statusEl.textContent = 'Stream address';
|
||||
statusEl.textContent = 'Stream-Adresse';
|
||||
const curated = document.getElementById('curated-lists');
|
||||
if (curated) curated.style.display = 'none';
|
||||
|
||||
|
|
@ -601,19 +619,19 @@ function showStreamAddressResult(url) {
|
|||
const actions = document.createElement('td');
|
||||
const play = document.createElement('button');
|
||||
play.className = 'btn btn-sm btn-play';
|
||||
play.innerHTML = '▶ Play';
|
||||
play.innerHTML = '▶ Abspielen';
|
||||
play.addEventListener('click', () => searchPlay(url, name, data));
|
||||
const save = document.createElement('button');
|
||||
save.className = 'btn btn-sm';
|
||||
save.innerHTML = '★ Save';
|
||||
save.innerHTML = '★ Speichern';
|
||||
save.style.marginLeft = '4px';
|
||||
save.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const chosen = await customPrompt('Name for this station', name);
|
||||
const chosen = await customPrompt('Name für diesen Sender', name);
|
||||
if (chosen == null) return;
|
||||
const finalName = chosen.trim() || name;
|
||||
await saveStation({...data, name: finalName});
|
||||
statusEl.textContent = `Saved as "${finalName}".`;
|
||||
statusEl.textContent = `Gespeichert als „${finalName}“.`;
|
||||
});
|
||||
actions.append(play, save);
|
||||
tr.appendChild(actions);
|
||||
|
|
@ -689,7 +707,7 @@ async function saveStation(station) {
|
|||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
await customAlert('Please log in to save stations.');
|
||||
await customAlert('Bitte anmelden, um Sender zu speichern.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -729,7 +747,7 @@ function addSavedRow(station) {
|
|||
<td>
|
||||
<button class="btn-icon fav-btn${station.is_favorite ? ' active' : ''}"
|
||||
onclick="toggleFav(${station.id}, this)"
|
||||
title="Toggle favorite">★</button>
|
||||
title="Favorit an/aus">★</button>
|
||||
</td>
|
||||
<td class="station-name-cell">${safeName}</td>
|
||||
<td>${safeBr}</td>
|
||||
|
|
@ -738,13 +756,13 @@ function addSavedRow(station) {
|
|||
<td>
|
||||
<button class="btn btn-sm"
|
||||
onclick="playStation('${safeUrl}', '${safeName}', ${station.id})">
|
||||
▶ Play
|
||||
▶ Abspielen
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="removeStation(${station.id})">
|
||||
Remove
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
|
@ -802,7 +820,7 @@ async function removeStation(pk) {
|
|||
if (tbody && tbody.querySelectorAll('tr').length === 0) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.id = 'saved-empty-row';
|
||||
tr.innerHTML = '<td colspan="7" class="empty-msg">No saved stations yet.</td>';
|
||||
tr.innerHTML = '<td colspan="7" class="empty-msg">Noch keine gespeicherten Sender.</td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
|
@ -834,14 +852,14 @@ function timerTick() {
|
|||
timerIsBreak = true;
|
||||
recordFocusSession();
|
||||
timerSeconds = TIMER_BREAK;
|
||||
showTimerNotification('Break time! 5 minutes.');
|
||||
showTimerNotification('Pause! 5 Minuten.');
|
||||
// auto-pause playback during break
|
||||
if (audio.src && !audio.paused) audio.pause();
|
||||
} else {
|
||||
// break ended → reset to work
|
||||
timerIsBreak = false;
|
||||
timerSeconds = TIMER_WORK;
|
||||
showTimerNotification('Break over. Back to work.');
|
||||
showTimerNotification('Pause vorbei. Weiter geht’s.');
|
||||
}
|
||||
renderTimer();
|
||||
}
|
||||
|
|
@ -879,7 +897,7 @@ function renderTimer() {
|
|||
const label = $('timer-phase-label');
|
||||
if (display) display.textContent = `${m}:${s}`;
|
||||
if (btn) btn.textContent = timerRunning ? '⏸' : '▶';
|
||||
if (label) label.textContent = timerIsBreak ? 'break' : 'focus';
|
||||
if (label) label.textContent = timerIsBreak ? 'Pause' : 'Fokus';
|
||||
// colour the display red when break
|
||||
if (display) display.style.color = timerIsBreak ? '#e63946' : '';
|
||||
}
|
||||
|
|
@ -918,7 +936,7 @@ async function loadFocusStats() {
|
|||
const widget = document.getElementById('focus-today-widget');
|
||||
if (widget) {
|
||||
if (data.today_sessions > 0) {
|
||||
widget.textContent = `Today: ${data.today_sessions} session${data.today_sessions !== 1 ? 's' : ''} · ${data.today_minutes} min`;
|
||||
widget.textContent = `Heute: ${data.today_sessions} ${data.today_sessions !== 1 ? 'Sitzungen' : 'Sitzung'} · ${data.today_minutes} min`;
|
||||
widget.style.display = '';
|
||||
} else {
|
||||
widget.style.display = 'none';
|
||||
|
|
@ -929,7 +947,7 @@ async function loadFocusStats() {
|
|||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!data.sessions.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="empty-msg">No focus sessions yet. Start the timer!</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="empty-msg">Noch keine Fokus-Sitzungen. Starte den Timer!</td></tr>';
|
||||
return;
|
||||
}
|
||||
data.sessions.forEach(s => {
|
||||
|
|
@ -988,14 +1006,14 @@ document.addEventListener('keydown', e => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOOD_TAGS = [
|
||||
{ label: 'Focus', tag: 'ambient' },
|
||||
{ label: 'Fokus', tag: 'ambient' },
|
||||
{ label: 'Lo-fi', tag: 'lofi' },
|
||||
{ label: 'Jazz', tag: 'jazz' },
|
||||
{ label: 'Classical', tag: 'classical' },
|
||||
{ label: 'Klassik', tag: 'classical' },
|
||||
{ label: 'Ambient', tag: 'ambient' },
|
||||
{ label: 'Metal', tag: 'metal' },
|
||||
{ label: 'Electronic', tag: 'electronic' },
|
||||
{ label: 'Talk', tag: 'talk' },
|
||||
{ label: 'Elektronisch', tag: 'electronic' },
|
||||
{ label: 'Wort', tag: 'talk' },
|
||||
];
|
||||
|
||||
function initMoodChips() {
|
||||
|
|
@ -1021,7 +1039,7 @@ function initMoodChips() {
|
|||
const CURATED_LISTS = [
|
||||
{
|
||||
id: 'focus',
|
||||
label: 'Focus',
|
||||
label: 'Fokus',
|
||||
stations: [
|
||||
{ name: 'SomaFM Drone Zone', url: 'https://ice6.somafm.com/dronezone-256-mp3' },
|
||||
{ name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac' },
|
||||
|
|
@ -1049,7 +1067,7 @@ const CURATED_LISTS = [
|
|||
},
|
||||
{
|
||||
id: 'classical',
|
||||
label: 'Classical',
|
||||
label: 'Klassik',
|
||||
stations: [
|
||||
{ name: 'BR Klassik', url: 'https://dispatcher.rndfnk.com/br/brklassik/live/mp3/high' },
|
||||
{ name: 'SWR Kultur', url: 'https://f111.rndfnk.com/ard/swr/swr2/live/mp3/256/stream.mp3?aggregator=web' },
|
||||
|
|
@ -1065,7 +1083,7 @@ function initCuratedLists() {
|
|||
if (INITIAL_FEATURED && INITIAL_FEATURED.length) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'curated-section';
|
||||
section.innerHTML = `<div class="curated-label">★ Featured</div>`;
|
||||
section.innerHTML = `<div class="curated-label">★ Empfohlen</div>`;
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'curated-stations';
|
||||
INITIAL_FEATURED.forEach(s => {
|
||||
|
|
@ -1122,8 +1140,8 @@ function maybeShowDonationHint(stationUrl, stationName) {
|
|||
const el = document.createElement('div');
|
||||
el.id = 'donation-hint';
|
||||
el.innerHTML = `
|
||||
<span>You listen to <strong>${escapeHtml(stationName)}</strong> a lot — consider supporting them ♥</span>
|
||||
<button onclick="dismissDonationHint('${escapeAttr(stationUrl)}')" title="Dismiss">✕</button>
|
||||
<span>Du hörst <strong>${escapeHtml(stationName)}</strong> oft – vielleicht magst du den Sender unterstützen ♥</span>
|
||||
<button onclick="dismissDonationHint('${escapeAttr(stationUrl)}')" title="Ausblenden">✕</button>
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
|
||||
|
|
@ -1141,7 +1159,7 @@ function dismissDonationHint(stationUrl) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function editNotes(pk, current) {
|
||||
const note = await customPrompt('Station note:', current || '');
|
||||
const note = await customPrompt('Notiz zum Sender:', current || '');
|
||||
if (note === null) return; // cancelled
|
||||
fetch(`/radio/notes/${pk}/`, {
|
||||
method: 'POST',
|
||||
|
|
@ -1315,7 +1333,7 @@ document.addEventListener('keyup', e => {
|
|||
});
|
||||
|
||||
function sanitizeSidebarHtml(html) {
|
||||
if (!html) return '<p class="muted">No show notes available.</p>';
|
||||
if (!html) return '<p class="muted">Keine Shownotes vorhanden.</p>';
|
||||
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 = '<p class="muted">Loading books…</p>';
|
||||
listEl.innerHTML = '<p class="muted">Bücher werden geladen …</p>';
|
||||
|
||||
try {
|
||||
listEl.innerHTML = '<p class="muted">Fetching book list from server…</p>';
|
||||
listEl.innerHTML = '<p class="muted">Bücherliste wird vom Server geholt …</p>';
|
||||
const res = await fetch('/books/', {cache: 'no-store'});
|
||||
if (!res.ok) {
|
||||
listEl.innerHTML = `<p class="muted">Server error ${res.status} loading books.</p>`;
|
||||
listEl.innerHTML = `<p class="muted">Serverfehler ${res.status} beim Laden der Bücher.</p>`;
|
||||
return;
|
||||
}
|
||||
const books = await res.json();
|
||||
_evictBookCache(books); // fire-and-forget
|
||||
if (!Array.isArray(books)) {
|
||||
listEl.innerHTML = `<p class="muted">Unexpected response from server (not an array).</p>`;
|
||||
listEl.innerHTML = `<p class="muted">Unerwartete Antwort vom Server.</p>`;
|
||||
return;
|
||||
}
|
||||
if (!books.length) {
|
||||
listEl.innerHTML = '<p class="muted">No books yet. Drop an .epub or .pdf above.</p>';
|
||||
listEl.innerHTML = '<p class="muted">Noch keine Bücher. Zieh oben eine .epub- oder .pdf-Datei hinein.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = `<p class="muted">Found ${books.length} book(s) on server. Decrypting…</p>`;
|
||||
listEl.innerHTML = `<p class="muted">${books.length === 1 ? '1 Buch' : books.length + ' Bücher'} auf dem Server. Entschlüssele …</p>`;
|
||||
|
||||
let key;
|
||||
try {
|
||||
key = await getOrCreateEncKey();
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<p class="muted">Encryption not available: ${e.message}. Make sure you are on HTTPS.</p>`;
|
||||
listEl.innerHTML = `<p class="muted">Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.</p>`;
|
||||
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 = `<p class="muted">Error loading books: ${e.message}</p>`;
|
||||
if (listEl) listEl.innerHTML = `<p class="muted">Fehler beim Laden der Bücher: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠ wrong key</span>' : '';
|
||||
const keyWarning = b.keyOk === false ? '<span title="Falscher Schlüssel – importiere den richtigen Schlüssel, um dieses Buch zu öffnen" style="color:var(--accent,#e63946);margin-left:4px;">⚠ falscher Schlüssel</span>' : '';
|
||||
const broken = _brokenBooks.has(b.id);
|
||||
return `<div class="book-item" data-book-id="${b.id}">
|
||||
<div class="book-item-info">
|
||||
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</strong>
|
||||
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
||||
<span class="book-item-meta-row">
|
||||
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
||||
${pct > 0 ? `<span class="muted book-progress">${pct} % gelesen</span>` : ''}
|
||||
${b.shelfTag ? `<span class="book-shelf-badge" title="Regal-Kategorie">${escapeHtml(b.shelfTag)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="book-item-actions">
|
||||
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
|
||||
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Erst den richtigen Schlüssel importieren"' : ''}>Öffnen</button>
|
||||
<div class="book-item-menu">
|
||||
<button class="btn btn-sm book-menu-toggle" title="Weitere Optionen" onclick="toggleBookMenu(this)">⋮</button>
|
||||
<div class="book-item-menu-list">
|
||||
|
|
@ -2689,7 +2707,7 @@ async function _updateBookMeta(bookId, overrides) {
|
|||
body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error('Server error');
|
||||
if (!data.ok) throw new Error('Serverfehler');
|
||||
Object.assign(book, overrides);
|
||||
bookMetaCache[bookId] = merged;
|
||||
_saveBookMeta(_lastBookListData);
|
||||
|
|
@ -2845,9 +2863,9 @@ async function deriveAndStoreKey() {
|
|||
const pwInput = document.getElementById('enc-key-password');
|
||||
const statusEl = $('enc-key-status');
|
||||
const pw = pwInput ? pwInput.value : '';
|
||||
if (!pw) { if (statusEl) statusEl.textContent = 'Please enter your password.'; return; }
|
||||
if (!pw) { if (statusEl) statusEl.textContent = 'Bitte gib dein Passwort ein.'; return; }
|
||||
|
||||
if (statusEl) statusEl.textContent = 'Deriving key…';
|
||||
if (statusEl) statusEl.textContent = 'Schlüssel wird abgeleitet …';
|
||||
try {
|
||||
const enc = new TextEncoder();
|
||||
const username = document.querySelector('meta[name="username"]')?.content || '';
|
||||
|
|
@ -2860,21 +2878,21 @@ async function deriveAndStoreKey() {
|
|||
const storageKey = `diora_enc_key_${window.USER_ID || 0}`;
|
||||
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
|
||||
_encKey = null; // reset cached key
|
||||
if (statusEl) statusEl.textContent = '✓ Unlocked';
|
||||
if (statusEl) statusEl.textContent = '✓ Entsperrt';
|
||||
const prompt = $('enc-key-prompt');
|
||||
const uploadArea = $('book-upload-area');
|
||||
if (prompt) prompt.style.display = 'none';
|
||||
if (uploadArea) uploadArea.style.display = '';
|
||||
loadBookList();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = 'Error: ' + err.message;
|
||||
if (statusEl) statusEl.textContent = 'Fehler: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadEbook(file) {
|
||||
const statusEl = $('book-upload-status');
|
||||
if (file.size > DIORA_CONFIG.ebookMaxBytes) {
|
||||
if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
||||
if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
||||
return false;
|
||||
}
|
||||
return _importEbookBuffer(await file.arrayBuffer(), file.name, statusEl);
|
||||
|
|
@ -2886,15 +2904,15 @@ async function _importEbookBuffer(buf, filename, statusEl) {
|
|||
const isPdf = /\.pdf$/i.test(filename);
|
||||
const isEpub = /\.epub$/i.test(filename);
|
||||
if (!isPdf && !isEpub) {
|
||||
if (statusEl) statusEl.textContent = 'Only .epub and .pdf files are supported.';
|
||||
if (statusEl) statusEl.textContent = 'Nur .epub- und .pdf-Dateien werden unterstützt.';
|
||||
return false;
|
||||
}
|
||||
if (buf.byteLength > DIORA_CONFIG.ebookMaxBytes) {
|
||||
if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
||||
if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (statusEl) statusEl.textContent = 'Encrypting…';
|
||||
if (statusEl) statusEl.textContent = 'Verschlüssele …';
|
||||
|
||||
try {
|
||||
let title = filename.replace(/\.(epub|pdf)$/i, '');
|
||||
|
|
@ -2932,7 +2950,7 @@ async function _importEbookBuffer(buf, filename, statusEl) {
|
|||
encryptBytes(key, buf),
|
||||
]);
|
||||
|
||||
if (statusEl) statusEl.textContent = 'Uploading…';
|
||||
if (statusEl) statusEl.textContent = 'Lade hoch …';
|
||||
|
||||
const res = await fetch('/books/upload/', {
|
||||
method: 'POST',
|
||||
|
|
@ -2946,14 +2964,14 @@ async function _importEbookBuffer(buf, filename, statusEl) {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
if (statusEl) statusEl.textContent = `✓ "${title}" uploaded`;
|
||||
if (statusEl) statusEl.textContent = `✓ „${title}“ hochgeladen`;
|
||||
loadBookList();
|
||||
return true;
|
||||
}
|
||||
if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed');
|
||||
if (statusEl) statusEl.textContent = 'Fehler: ' + (data.error ? _germanError(data.error) : 'Hochladen fehlgeschlagen');
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = 'Upload failed: ' + e.message;
|
||||
if (statusEl) statusEl.textContent = 'Hochladen fehlgeschlagen: ' + e.message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -3536,7 +3554,7 @@ async function _openBookOnce(bookId) {
|
|||
const titleEl = $('reader-title');
|
||||
if (!overlay || !contentEl) return;
|
||||
|
||||
titleEl.textContent = 'Loading…';
|
||||
titleEl.textContent = 'Lade …';
|
||||
contentEl.innerHTML = '';
|
||||
overlay.style.display = '';
|
||||
|
||||
|
|
@ -3817,20 +3835,20 @@ async function exportEncKey() {
|
|||
const raw = await crypto.subtle.exportKey('raw', key);
|
||||
const b64 = bytesToBase64(raw);
|
||||
await navigator.clipboard.writeText(b64);
|
||||
if (statusEl) statusEl.textContent = '✓ Key copied to clipboard';
|
||||
if (statusEl) statusEl.textContent = '✓ Schlüssel in die Zwischenablage kopiert';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000);
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = 'Export failed: ' + e.message;
|
||||
if (statusEl) statusEl.textContent = 'Export fehlgeschlagen: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function showImportKey() {
|
||||
const body = $('sidebar-body');
|
||||
openSidebar('Import encryption key', `
|
||||
<p class="muted">Paste the key exported from your other browser:</p>
|
||||
openSidebar('Schlüssel importieren', `
|
||||
<p class="muted">Füge den Schlüssel ein, den du im anderen Browser exportiert hast:</p>
|
||||
<textarea id="import-key-input" class="search-input" rows="3" style="width:100%;resize:none;font-family:monospace;font-size:0.75rem;"></textarea>
|
||||
<button class="btn" style="margin-top:8px;" data-import-key-apply>Apply</button>
|
||||
<p class="muted" style="margin-top:8px;">This replaces the key in this browser. Books uploaded here won't be readable until you sync the key back.</p>
|
||||
<button class="btn" style="margin-top:8px;" data-import-key-apply>Übernehmen</button>
|
||||
<p class="muted" style="margin-top:8px;">Das ersetzt den Schlüssel in diesem Browser. Hier hochgeladene Bücher sind erst wieder lesbar, wenn du den Schlüssel zurück abgleichst.</p>
|
||||
`);
|
||||
body.addEventListener('click', async function _importClick(e) {
|
||||
if (!e.target.closest('[data-import-key-apply]')) return;
|
||||
|
|
@ -3843,17 +3861,17 @@ function showImportKey() {
|
|||
const re_exported = await crypto.subtle.exportKey('raw', importedKey);
|
||||
localStorage.setItem(`diora_enc_key_${window.USER_ID}`, bytesToBase64(re_exported));
|
||||
closeSidebar();
|
||||
if (statusEl) statusEl.textContent = '✓ Key imported — reloading books…';
|
||||
if (statusEl) statusEl.textContent = '✓ Schlüssel importiert – Bücher werden neu geladen …';
|
||||
await loadBookList();
|
||||
if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||||
} catch (e) {
|
||||
if ($('book-key-status')) $('book-key-status').textContent = 'Import failed: invalid key';
|
||||
if ($('book-key-status')) $('book-key-status').textContent = 'Import fehlgeschlagen: ungültiger Schlüssel';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteBook(bookId) {
|
||||
if (!await customConfirm('Delete this book? This cannot be undone.', { danger: true })) return;
|
||||
if (!await customConfirm('Dieses Buch löschen? Das lässt sich nicht rückgängig machen.', { danger: true })) return;
|
||||
try {
|
||||
const res = await fetch(`/books/${bookId}/delete/`, {
|
||||
method: 'POST',
|
||||
|
|
@ -4000,7 +4018,7 @@ function closeReader() {
|
|||
|
||||
function openTocSidebar() {
|
||||
if (!currentBookToc.length) {
|
||||
openSidebar('Table of Contents', '<p class="muted">No table of contents found in this book.</p>');
|
||||
openSidebar('Inhaltsverzeichnis', '<p class="muted">Dieses Buch hat kein Inhaltsverzeichnis.</p>');
|
||||
return;
|
||||
}
|
||||
let html = '<ul class="toc-list">';
|
||||
|
|
@ -4012,7 +4030,7 @@ function openTocSidebar() {
|
|||
</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
openSidebar('Table of Contents', html);
|
||||
openSidebar('Inhaltsverzeichnis', html);
|
||||
// Attach delegated listener after sidebar body is populated
|
||||
const body = $('sidebar-body');
|
||||
body.addEventListener('click', function _tocClick(e) {
|
||||
|
|
@ -4187,25 +4205,25 @@ function toggleSettingsPanel() {
|
|||
|
||||
if (!isPdf) {
|
||||
panel.innerHTML = `
|
||||
<label>Font <button class="btn btn-sm" id="rs-font-minus">−</button> <input type="range" id="rs-font" min="12" max="24" step="1" value="${readerSettings.fontSize}"> <button class="btn btn-sm" id="rs-font-plus">+</button> <span id="rs-font-val">${readerSettings.fontSize}px</span></label>
|
||||
<label>Line <button class="btn btn-sm" id="rs-line-minus">−</button> <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <button class="btn btn-sm" id="rs-line-plus">+</button> <span id="rs-line-val">${readerSettings.lineHeight}</span></label>
|
||||
<label>Width <button class="btn btn-sm" id="rs-width-minus">−</button> <input type="range" id="rs-width" min="40" max="${_READER_WIDTH_MAX}" step="5" value="${Math.min(readerSettings.maxWidth, _READER_WIDTH_MAX)}"> <button class="btn btn-sm" id="rs-width-plus">+</button> <span id="rs-width-val">${readerSettings.maxWidth}ch</span></label>
|
||||
<button class="btn btn-sm" id="rs-width-full">Full</button>
|
||||
<label>Weight <button class="btn btn-sm" id="rs-weight-minus">−</button> <input type="range" id="rs-weight" min="200" max="800" step="${_readerWeightStep()}" value="${_readerWeight()}"> <button class="btn btn-sm" id="rs-weight-plus">+</button> <span id="rs-weight-val">${_readerWeight()}</span></label>
|
||||
<label>Schrift <button class="btn btn-sm" id="rs-font-minus">−</button> <input type="range" id="rs-font" min="12" max="24" step="1" value="${readerSettings.fontSize}"> <button class="btn btn-sm" id="rs-font-plus">+</button> <span id="rs-font-val">${readerSettings.fontSize}px</span></label>
|
||||
<label>Zeilen <button class="btn btn-sm" id="rs-line-minus">−</button> <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <button class="btn btn-sm" id="rs-line-plus">+</button> <span id="rs-line-val">${readerSettings.lineHeight}</span></label>
|
||||
<label>Breite <button class="btn btn-sm" id="rs-width-minus">−</button> <input type="range" id="rs-width" min="40" max="${_READER_WIDTH_MAX}" step="5" value="${Math.min(readerSettings.maxWidth, _READER_WIDTH_MAX)}"> <button class="btn btn-sm" id="rs-width-plus">+</button> <span id="rs-width-val">${readerSettings.maxWidth}ch</span></label>
|
||||
<button class="btn btn-sm" id="rs-width-full">Volle Breite</button>
|
||||
<label>Stärke <button class="btn btn-sm" id="rs-weight-minus">−</button> <input type="range" id="rs-weight" min="200" max="800" step="${_readerWeightStep()}" value="${_readerWeight()}"> <button class="btn btn-sm" id="rs-weight-plus">+</button> <span id="rs-weight-val">${_readerWeight()}</span></label>
|
||||
<button class="btn btn-sm ${readerSettings.fontFamily === 'serif' ? 'active' : ''}" data-rs-font="serif">Serif</button>
|
||||
<button class="btn btn-sm ${readerSettings.fontFamily === 'sans' ? 'active' : ''}" data-rs-font="sans">Sans</button>
|
||||
<button class="btn btn-sm ${readerSettings.fontFamily === 'humanist' ? 'active' : ''}" data-rs-font="humanist">Verdana</button>
|
||||
<button class="btn btn-sm ${readerSettings.fontFamily === 'mono' ? 'active' : ''}" data-rs-font="mono">Mono</button>
|
||||
<button class="btn btn-sm ${readerSettings.noBold ? 'active' : ''}" id="rs-no-bold">Kein Fett</button>
|
||||
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dark</button>
|
||||
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dunkel</button>
|
||||
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
|
||||
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Bright</button>
|
||||
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Hell</button>
|
||||
`;
|
||||
} else {
|
||||
panel.innerHTML = `
|
||||
<label>Zoom <button class="btn btn-sm" id="rs-zoom-minus">−</button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
|
||||
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invert</button>
|
||||
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Spread</button>
|
||||
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invertieren</button>
|
||||
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Doppelseite</button>
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -4651,7 +4669,7 @@ function addBookmark() {
|
|||
|
||||
if (currentPdfDoc) {
|
||||
const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1;
|
||||
label = `Page ${page}`;
|
||||
label = `Seite ${page}`;
|
||||
anchor = `pdf-page-${page}`;
|
||||
scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1);
|
||||
} else {
|
||||
|
|
@ -4685,14 +4703,14 @@ function addBookmark() {
|
|||
// Toast
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'reader-toast';
|
||||
toast.textContent = `★ Bookmarked: ${label}`;
|
||||
toast.textContent = `★ Lesezeichen: ${label}`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2200);
|
||||
}
|
||||
|
||||
function openBookmarksSidebar() {
|
||||
if (!currentBookmarks.length) {
|
||||
openSidebar('Bookmarks', '<p class="muted">No bookmarks yet. Press ★ while reading.</p>');
|
||||
openSidebar('Lesezeichen', '<p class="muted">Noch keine Lesezeichen. Tippe beim Lesen auf ★.</p>');
|
||||
return;
|
||||
}
|
||||
let html = '<ul style="list-style:none;padding:0;">';
|
||||
|
|
@ -4701,11 +4719,11 @@ function openBookmarksSidebar() {
|
|||
<button class="btn-link" data-jump-bookmark="${escapeHtml(bm.id)}" style="flex:1;text-align:left;">
|
||||
${escapeHtml(bm.label)}
|
||||
</button>
|
||||
<button class="btn-icon" data-delete-bookmark="${escapeHtml(bm.id)}" title="Delete">✕</button>
|
||||
<button class="btn-icon" data-delete-bookmark="${escapeHtml(bm.id)}" title="Löschen">✕</button>
|
||||
</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
openSidebar('Bookmarks', html);
|
||||
openSidebar('Lesezeichen', html);
|
||||
|
||||
const body = $('sidebar-body');
|
||||
body.addEventListener('click', function _bmClick(e) {
|
||||
|
|
@ -4780,9 +4798,9 @@ function toggleReaderSearch() {
|
|||
bar.id = 'reader-search-bar';
|
||||
bar.className = 'reader-search-bar';
|
||||
bar.innerHTML = `
|
||||
<input type="text" id="reader-search-input" class="search-input" placeholder="Search…" style="width:160px;">
|
||||
<button class="btn-icon" id="rs-search-prev" title="Previous">↑</button>
|
||||
<button class="btn-icon" id="rs-search-next" title="Next">↓</button>
|
||||
<input type="text" id="reader-search-input" class="search-input" placeholder="Suchen …" style="width:160px;">
|
||||
<button class="btn-icon" id="rs-search-prev" title="Vorheriger Treffer">↑</button>
|
||||
<button class="btn-icon" id="rs-search-next" title="Nächster Treffer">↓</button>
|
||||
<span id="rs-search-count" class="muted"></span>
|
||||
<button class="btn-icon" id="rs-search-clear" title="Close">✕</button>
|
||||
`;
|
||||
|
|
@ -5180,11 +5198,11 @@ function showHighlightPopover(range) {
|
|||
popover.id = 'highlight-popover';
|
||||
popover.className = 'highlight-popover';
|
||||
popover.innerHTML = `
|
||||
<button class="hl-color-btn" data-hl-color="yellow" style="background:#f1c40f" title="Yellow">A</button>
|
||||
<button class="hl-color-btn" data-hl-color="green" style="background:#2ecc71" title="Green">A</button>
|
||||
<button class="hl-color-btn" data-hl-color="blue" style="background:#3498db" title="Blue">A</button>
|
||||
<button class="hl-color-btn" data-hl-color="red" style="background:#e63946" title="Red">A</button>
|
||||
<button class="hl-note-btn" title="Add note">✎</button>
|
||||
<button class="hl-color-btn" data-hl-color="yellow" style="background:#f1c40f" title="Gelb">A</button>
|
||||
<button class="hl-color-btn" data-hl-color="green" style="background:#2ecc71" title="Grün">A</button>
|
||||
<button class="hl-color-btn" data-hl-color="blue" style="background:#3498db" title="Blau">A</button>
|
||||
<button class="hl-color-btn" data-hl-color="red" style="background:#e63946" title="Rot">A</button>
|
||||
<button class="hl-note-btn" title="Notiz hinzufügen">✎</button>
|
||||
`;
|
||||
popover.style.visibility = 'hidden'; // measure before showing, to avoid a flash at the wrong spot
|
||||
document.body.appendChild(popover);
|
||||
|
|
@ -5226,12 +5244,12 @@ function showHighlightTooltip(markEl, h) {
|
|||
popover.className = 'highlight-popover';
|
||||
popover.style.flexDirection = 'column';
|
||||
popover.style.maxWidth = '220px';
|
||||
const noteText = h?.note ? escapeHtml(h.note) : '<span class="muted">No note</span>';
|
||||
const noteText = h?.note ? escapeHtml(h.note) : '<span class="muted">Keine Notiz</span>';
|
||||
popover.innerHTML = `
|
||||
<div style="font-size:12px;padding-bottom:4px;">${noteText}</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn btn-sm" data-hl-edit-note="${escapeHtml(h?.id || '')}">Edit note</button>
|
||||
<button class="btn btn-sm btn-danger" data-hl-delete="${escapeHtml(h?.id || '')}">Delete</button>
|
||||
<button class="btn btn-sm" data-hl-edit-note="${escapeHtml(h?.id || '')}">Notiz bearbeiten</button>
|
||||
<button class="btn btn-sm btn-danger" data-hl-delete="${escapeHtml(h?.id || '')}">Löschen</button>
|
||||
</div>
|
||||
`;
|
||||
popover.style.visibility = 'hidden';
|
||||
|
|
@ -5659,11 +5677,11 @@ async function downloadBookAnnotationsFromList(bookId, btnEl) {
|
|||
try {
|
||||
const res = await fetch(`/books/${bookId}/highlights/`);
|
||||
const {ct, iv} = await res.json();
|
||||
if (!ct) { await customAlert('No highlights or notes in this book yet.'); return; }
|
||||
if (!ct) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; }
|
||||
const key = await getOrCreateEncKey();
|
||||
const plain = await decryptBytes(key, iv, ct);
|
||||
const highlights = JSON.parse(new TextDecoder().decode(plain));
|
||||
if (!highlights.length) { await customAlert('No highlights or notes in this book yet.'); return; }
|
||||
if (!highlights.length) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; }
|
||||
|
||||
highlights.sort((a, b) => {
|
||||
const ab = a.anchor?.startBlockIndex ?? 0, bb = b.anchor?.startBlockIndex ?? 0;
|
||||
|
|
@ -5671,22 +5689,22 @@ async function downloadBookAnnotationsFromList(bookId, btnEl) {
|
|||
return (a.anchor?.startChar ?? 0) - (b.anchor?.startChar ?? 0);
|
||||
});
|
||||
|
||||
const bookTitle = bookMetaCache[bookId]?.title || `Book #${bookId}`;
|
||||
const lines = [`${bookTitle} — Annotations`, `Exported: ${new Date().toISOString().slice(0, 10)}`, ''];
|
||||
const bookTitle = bookMetaCache[bookId]?.title || `Buch #${bookId}`;
|
||||
const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, ''];
|
||||
|
||||
let lastChapter = null;
|
||||
for (const h of highlights) {
|
||||
const chapterTitle = h.anchor?.chapterSrc || '';
|
||||
if (chapterTitle !== lastChapter) {
|
||||
lines.push(`── ${chapterTitle || 'Untitled section'} ──`);
|
||||
lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`);
|
||||
lastChapter = chapterTitle;
|
||||
}
|
||||
if (h.type === 'note') {
|
||||
lines.push(`[Note] near: "${(h.anchor?.quote || '').trim()}"`);
|
||||
lines.push(`[Notiz] bei: „${(h.anchor?.quote || '').trim()}“`);
|
||||
if (h.note) lines.push(` ${h.note}`);
|
||||
} else {
|
||||
lines.push(`[Highlight · ${h.color || 'yellow'}] "${(h.anchor?.quote || '').trim()}"`);
|
||||
if (h.note) lines.push(` Note: ${h.note}`);
|
||||
lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}] „${(h.anchor?.quote || '').trim()}“`);
|
||||
if (h.note) lines.push(` Notiz: ${h.note}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
|
@ -5695,13 +5713,13 @@ async function downloadBookAnnotationsFromList(bookId, btnEl) {
|
|||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-annotations.txt`;
|
||||
a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
await customAlert('Export failed: ' + e.message);
|
||||
await customAlert('Export fehlgeschlagen: ' + e.message);
|
||||
} finally {
|
||||
if (btnEl) btnEl.disabled = false;
|
||||
}
|
||||
|
|
@ -5756,33 +5774,38 @@ function _sortedAnnotations() {
|
|||
return resolved;
|
||||
}
|
||||
|
||||
// Highlight colours as the export and the list name them.
|
||||
function _hlColorName(color) {
|
||||
return ({yellow: 'gelb', green: 'grün', blue: 'blau', red: 'rot'})[color] || color;
|
||||
}
|
||||
|
||||
function exportAnnotations() {
|
||||
if (!currentHighlights.length) {
|
||||
customAlert('No highlights or notes in this book yet.');
|
||||
customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = _sortedAnnotations();
|
||||
|
||||
const titleEl = $('reader-title');
|
||||
const bookTitle = (titleEl?.textContent || 'Book').split(' — ')[0];
|
||||
const lines = [`${bookTitle} — Annotations`, `Exported: ${new Date().toISOString().slice(0, 10)}`, ''];
|
||||
const bookTitle = (titleEl?.textContent || 'Buch').split(' — ')[0];
|
||||
const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, ''];
|
||||
|
||||
let lastChapter = null;
|
||||
for (const {h, range} of resolved) {
|
||||
const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || '';
|
||||
if (chapterTitle !== lastChapter) {
|
||||
lines.push(`── ${chapterTitle || 'Untitled section'} ──`);
|
||||
lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`);
|
||||
lastChapter = chapterTitle;
|
||||
}
|
||||
const pct = _percentFromRange(range);
|
||||
const coord = pct !== null ? ` (≈${_formatBookPercent(pct / 100)}% im Buch)` : '';
|
||||
if (h.type === 'note') {
|
||||
lines.push(`[Note]${coord} near: "${(h.anchor?.quote || '').trim()}"`);
|
||||
lines.push(`[Notiz]${coord} bei: „${(h.anchor?.quote || '').trim()}“`);
|
||||
if (h.note) lines.push(` ${h.note}`);
|
||||
} else {
|
||||
lines.push(`[Highlight · ${h.color || 'yellow'}]${coord} "${(h.anchor?.quote || '').trim()}"`);
|
||||
if (h.note) lines.push(` Note: ${h.note}`);
|
||||
lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}]${coord} „${(h.anchor?.quote || '').trim()}“`);
|
||||
if (h.note) lines.push(` Notiz: ${h.note}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
|
@ -5791,7 +5814,7 @@ function exportAnnotations() {
|
|||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-annotations.txt`;
|
||||
a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
|
|
@ -5804,7 +5827,7 @@ function exportAnnotations() {
|
|||
// overview, useful once a book has annotations scattered throughout.
|
||||
function openAnnotationsSidebar() {
|
||||
if (!currentHighlights.length) {
|
||||
openSidebar('Highlights & Notes', '<p class="muted">No highlights or notes yet.</p>');
|
||||
openSidebar('Markierungen & Notizen', '<p class="muted">Noch keine Markierungen oder Notizen.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -5813,18 +5836,18 @@ function openAnnotationsSidebar() {
|
|||
for (const {h, range} of resolved) {
|
||||
const pct = _percentFromRange(range);
|
||||
const pctLabel = pct !== null ? `≈${_formatBookPercent(pct / 100)}%` : '';
|
||||
const kind = h.type === 'note' ? 'Note' : `Highlight${h.color ? ' · ' + h.color : ''}`;
|
||||
const preview = h.note || h.anchor?.quote || '(no text)';
|
||||
const kind = h.type === 'note' ? 'Notiz' : `Markierung${h.color ? ' · ' + _hlColorName(h.color) : ''}`;
|
||||
const preview = h.note || h.anchor?.quote || '(kein Text)';
|
||||
html += `<li class="bookmark-entry">
|
||||
<button class="btn-link" data-jump-annotation="${escapeHtml(h.id)}" style="flex:1;text-align:left;">
|
||||
<span class="muted" style="font-size:11px;">${escapeHtml(kind)} ${pctLabel}</span><br>
|
||||
${escapeHtml(preview.slice(0, 80))}
|
||||
</button>
|
||||
<button class="btn-icon" data-delete-annotation="${escapeHtml(h.id)}" title="Delete">✕</button>
|
||||
<button class="btn-icon" data-delete-annotation="${escapeHtml(h.id)}" title="Löschen">✕</button>
|
||||
</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
openSidebar('Highlights & Notes', html);
|
||||
openSidebar('Markierungen & Notizen', html);
|
||||
|
||||
const body = $('sidebar-body');
|
||||
body.addEventListener('click', function _annClick(e) {
|
||||
|
|
@ -5918,14 +5941,14 @@ function dismissFootnotePopover() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openRadioSidebar() {
|
||||
const stationName = currentStation ? escapeHtml(currentStation.name) : '— no station —';
|
||||
const stationName = currentStation ? escapeHtml(currentStation.name) : '— kein Sender —';
|
||||
const track = currentTrack ? escapeHtml(currentTrack) : '';
|
||||
const vol = document.getElementById('volume')?.value ?? 204;
|
||||
|
||||
const rows = [...document.querySelectorAll('#saved-tbody tr[data-id]')];
|
||||
const stationsHtml = rows.length
|
||||
? rows.map(r => `<li><button class="btn btn-sm rsb-play-btn" data-url="${escapeHtml(r.dataset.url || '')}" data-name="${escapeHtml(r.dataset.name || '')}">${escapeHtml(r.dataset.name || '')}</button></li>`).join('')
|
||||
: `<li class="muted">No saved stations.</li>`;
|
||||
: `<li class="muted">Keine gespeicherten Sender.</li>`;
|
||||
|
||||
const html = `
|
||||
<div class="rsb-nowplaying">
|
||||
|
|
@ -5933,8 +5956,8 @@ function openRadioSidebar() {
|
|||
${track ? `<div class="rsb-track muted">${track}</div>` : ''}
|
||||
</div>
|
||||
<div class="rsb-controls">
|
||||
<button class="btn ${isPlaying ? 'playing' : ''}" id="rsb-playstop">${isPlaying ? '⏹ Stop' : '▶ Play'}</button>
|
||||
<label class="rsb-vol">vol <input type="range" id="rsb-volume" min="0" max="255" value="${vol}"></label>
|
||||
<button class="btn ${isPlaying ? 'playing' : ''}" id="rsb-playstop">${isPlaying ? '⏹ Stopp' : '▶ Abspielen'}</button>
|
||||
<label class="rsb-vol">Lautst. <input type="range" id="rsb-volume" min="0" max="255" value="${vol}"></label>
|
||||
</div>
|
||||
<ul class="rsb-station-list">${stationsHtml}</ul>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v70';
|
||||
const CACHE = 'diora-v71';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app.js',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "diora",
|
||||
"short_name": "diora",
|
||||
"description": "Internet radio player",
|
||||
"description": "Bücher, Podcasts und Radio",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Login — diora{% endblock %}
|
||||
{% block title %}Anmelden — diora{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<h1 class="auth-title">Login</h1>
|
||||
<h1 class="auth-title">Anmelden</h1>
|
||||
<form method="post" class="auth-form">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
|
|
@ -26,10 +26,10 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary btn-full">Login</button>
|
||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||
</form>
|
||||
<p class="auth-switch">
|
||||
Don't have an account? <a href="{% url 'register' %}">Register</a>
|
||||
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Register — diora{% endblock %}
|
||||
{% block title %}Registrieren — diora{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<h1 class="auth-title">Create Account</h1>
|
||||
<h1 class="auth-title">Konto anlegen</h1>
|
||||
<form method="post" class="auth-form">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
|
|
@ -29,10 +29,10 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary btn-full">Create Account</button>
|
||||
<button type="submit" class="btn btn-primary btn-full">Konto anlegen</button>
|
||||
</form>
|
||||
<p class="auth-switch">
|
||||
Already have an account? <a href="{% url 'login' %}">Login</a>
|
||||
Schon ein Konto? <a href="{% url 'login' %}">Anmelden</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Settings — diora{% endblock %}
|
||||
{% block title %}Einstellungen — diora{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-container">
|
||||
<h1 class="settings-title">Settings</h1>
|
||||
<h1 class="settings-title">Einstellungen</h1>
|
||||
|
||||
{% if lastfm_error %}
|
||||
<div class="message message-error">{{ lastfm_error }}</div>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
{% if has_lastfm %}
|
||||
<div class="lastfm-connected">
|
||||
<p class="connected-status">
|
||||
Connected as <strong>{{ profile.lastfm_username }}</strong>
|
||||
Verbunden als <strong>{{ profile.lastfm_username }}</strong>
|
||||
</p>
|
||||
<form method="post" class="settings-form" id="scrobble-form">
|
||||
{% csrf_token %}
|
||||
|
|
@ -23,46 +23,46 @@
|
|||
<input type="checkbox" name="lastfm_scrobble" id="lastfm_scrobble"
|
||||
{% if profile.lastfm_scrobble %}checked{% endif %}
|
||||
onchange="document.getElementById('scrobble-form').submit()">
|
||||
Scrobble tracks to Last.fm
|
||||
Titel an Last.fm scrobbeln
|
||||
</label>
|
||||
</form>
|
||||
<form method="post" action="{% url 'lastfm_disconnect' %}" class="inline-form" style="margin-top: 1rem;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger">Disconnect Last.fm</button>
|
||||
<button type="submit" class="btn btn-danger">Last.fm trennen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="lastfm-description">
|
||||
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.
|
||||
</p>
|
||||
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Connect Last.fm</a>
|
||||
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Last.fm verbinden</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- Background section -->
|
||||
<section class="settings-section">
|
||||
<h2>Background</h2>
|
||||
<h2>Hintergrund</h2>
|
||||
{% if request.user.profile.background_encrypted %}
|
||||
<p class="lastfm-description">Encrypted background is set. <span class="muted">(Preview not available — decrypted in browser on main page.)</span></p>
|
||||
<p class="lastfm-description">Ein verschlüsselter Hintergrund ist gesetzt. <span class="muted">(Keine Vorschau – er wird erst auf der Hauptseite im Browser entschlüsselt.)</span></p>
|
||||
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger">Remove background</button>
|
||||
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
|
||||
</form>
|
||||
<p style="margin-top:12px;">Upload a new image to replace it:</p>
|
||||
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen:</p>
|
||||
{% elif request.user.profile.background_image_data %}
|
||||
<p>
|
||||
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Your background">
|
||||
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Dein Hintergrund">
|
||||
</p>
|
||||
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger">Remove background</button>
|
||||
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
|
||||
</form>
|
||||
<p style="margin-top:12px;">Upload a new image to replace it (will be stored encrypted):</p>
|
||||
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen (wird verschlüsselt gespeichert):</p>
|
||||
{% else %}
|
||||
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB). Stored end-to-end encrypted.</p>
|
||||
<p class="lastfm-description">Eigenes Hintergrundbild hochladen (JPG, PNG oder WebP, höchstens 5 MB). Es wird Ende-zu-Ende-verschlüsselt gespeichert.</p>
|
||||
{% endif %}
|
||||
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
|
||||
<label class="btn" for="bg-upload-input">Choose file</label>
|
||||
<label class="btn" for="bg-upload-input">Datei auswählen</label>
|
||||
<input type="file" id="bg-upload-input" accept=".jpg,.jpeg,.png,.webp" style="display:none;" onchange="uploadBackground(this)">
|
||||
<span id="bg-upload-status" style="font-size:0.85rem; color:#888;"></span>
|
||||
</div>
|
||||
|
|
@ -70,11 +70,11 @@
|
|||
|
||||
<!-- Account section -->
|
||||
<section class="settings-section">
|
||||
<h2>Account</h2>
|
||||
<p>Logged in as <strong>{{ request.user.username }}</strong></p>
|
||||
<h2>Konto</h2>
|
||||
<p>Angemeldet als <strong>{{ request.user.username }}</strong></p>
|
||||
|
||||
<details {% if password_form_open %}open{% endif %} style="margin-top:1rem;">
|
||||
<summary class="btn" style="display:inline-block;cursor:pointer;">Change password</summary>
|
||||
<summary class="btn" style="display:inline-block;cursor:pointer;">Passwort ändern</summary>
|
||||
<form id="pw-change-form" method="post" action="{% url 'change_password' %}" style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem; max-width:320px;">
|
||||
{% csrf_token %}
|
||||
{% for field in password_form %}
|
||||
|
|
@ -90,13 +90,13 @@
|
|||
<div class="message message-error" style="padding:4px 8px; font-size:0.8rem;">{{ password_form.non_field_errors|join:", " }}</div>
|
||||
{% endif %}
|
||||
<div id="pw-change-status" style="font-size:0.85rem; color:#888;"></div>
|
||||
<div><button type="submit" class="btn">Save</button></div>
|
||||
<div><button type="submit" class="btn">Speichern</button></div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn">Logout</button>
|
||||
<button type="submit" class="btn">Abmelden</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
|
@ -249,20 +249,20 @@ async function uploadBackground(input) {
|
|||
const status = document.getElementById('bg-upload-status');
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
status.textContent = 'Only JPEG, PNG, or WebP images are allowed.';
|
||||
status.textContent = 'Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.';
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
status.textContent = 'Image must be 5 MB or smaller.';
|
||||
status.textContent = 'Das Bild darf höchstens 5 MB groß sein.';
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Encrypting…';
|
||||
status.textContent = 'Verschlüssele …';
|
||||
try {
|
||||
const key = await _getOrCreateEncKey();
|
||||
const buf = await file.arrayBuffer();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, buf);
|
||||
status.textContent = 'Uploading…';
|
||||
status.textContent = 'Lade hoch …';
|
||||
const res = await fetch('/accounts/background/upload/', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||
|
|
@ -270,9 +270,10 @@ async function uploadBackground(input) {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) { location.reload(); }
|
||||
else { status.textContent = data.error || 'Upload failed'; }
|
||||
else { status.textContent = data.error === 'only jpeg, png, or webp allowed' ? 'Nur JPEG, PNG oder WebP erlaubt.'
|
||||
: data.error === 'file too large (max 5 MB)' ? 'Datei zu groß (höchstens 5 MB).' : (data.error || 'Hochladen fehlgeschlagen'); }
|
||||
} catch (e) {
|
||||
status.textContent = 'Upload failed: ' + e.message;
|
||||
status.textContent = 'Hochladen fehlgeschlagen: ' + e.message;
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="diora">
|
||||
<meta name="description" content="Internet radio player">
|
||||
<meta name="description" content="Bücher, Podcasts und Radio">
|
||||
{% if user.is_authenticated %}<meta name="username" content="{{ user.username }}">{% endif %}
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||
|
|
@ -22,17 +22,17 @@
|
|||
<nav class="navbar">
|
||||
<a href="/" class="navbar-brand">diora</a>
|
||||
<div class="navbar-links">
|
||||
<button class="btn-icon contrast-toggle" id="contrast-toggle" onclick="toggleContrast()" title="Toggle contrast mode">◑</button>
|
||||
<button class="btn-icon contrast-toggle" id="contrast-toggle" onclick="toggleContrast()" title="Kontrastmodus an/aus">◑</button>
|
||||
{% if user.is_authenticated %}
|
||||
<span class="navbar-user">{{ user.username }}</span>
|
||||
<a href="{% url 'settings' %}">Settings</a>
|
||||
<a href="{% url 'settings' %}">Einstellungen</a>
|
||||
<form method="post" action="{% url 'logout' %}" class="inline-form">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-link">Logout</button>
|
||||
<button type="submit" class="btn-link">Abmelden</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{% url 'login' %}">Login</a>
|
||||
<a href="{% url 'register' %}">Register</a>
|
||||
<a href="{% url 'login' %}">Anmelden</a>
|
||||
<a href="{% url 'register' %}">Registrieren</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}diora — radio player{% endblock %}
|
||||
{% block title %}diora — Radio{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- ===== NOW PLAYING BAR ===== -->
|
||||
<section class="now-playing-bar" id="now-playing-bar">
|
||||
<div class="now-playing-info">
|
||||
<span class="now-playing-station" id="now-playing-station">— no station —</span>
|
||||
<span class="now-playing-station" id="now-playing-station">— kein Sender —</span>
|
||||
<span class="now-playing-track" id="now-playing-track"></span>
|
||||
</div>
|
||||
<div class="now-playing-controls">
|
||||
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">▶ Play</button>
|
||||
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">▶ Abspielen</button>
|
||||
<label class="volume-label">
|
||||
<span>vol</span>
|
||||
<span>Lautst.</span>
|
||||
<input type="range" id="volume" min="0" max="255" value="102" class="volume-slider">
|
||||
<input type="number" id="volume-num" min="0" max="255" value="102" class="volume-num">
|
||||
</label>
|
||||
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
||||
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Speichern</button>
|
||||
<button class="btn-icon" id="creamfresh-vote-up-btn" style="display:none;" onclick="creamfreshVote('up')" title="Gefällt mir">👍</button>
|
||||
<button class="btn-icon" id="creamfresh-vote-down-btn" style="display:none;" onclick="creamfreshVote('down')" title="Gefällt mir nicht">👎</button>
|
||||
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
|
||||
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Fokusmodus (blendet die Oberfläche aus, Esc beendet ihn)">⊙</button>
|
||||
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button>
|
||||
</div>
|
||||
<div class="timer-widget" id="timer-widget">
|
||||
<span class="timer-phase" id="timer-phase-label">focus</span>
|
||||
<span class="timer-phase" id="timer-phase-label">Fokus</span>
|
||||
<span class="timer-display" id="timer-display">25:00</span>
|
||||
<button class="btn-icon" id="timer-toggle-btn" onclick="toggleTimer()" title="Start/pause timer">▶</button>
|
||||
<button class="btn-icon" id="timer-reset-btn" onclick="resetTimer()" title="Reset timer">↺</button>
|
||||
<button class="btn-icon" id="timer-toggle-btn" onclick="toggleTimer()" title="Timer starten/anhalten">▶</button>
|
||||
<button class="btn-icon" id="timer-reset-btn" onclick="resetTimer()" title="Timer zurücksetzen">↺</button>
|
||||
<span class="focus-today" id="focus-today-widget" style="display:none;"></span>
|
||||
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Toggle black background">☼</button>
|
||||
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Schwarzer Hintergrund an/aus">☼</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== AFFILIATE / TRACK INFO ===== -->
|
||||
<section class="affiliate-section" id="affiliate-section" style="display:none;"{% if not amazon_enabled %} data-disabled="true"{% endif %}>
|
||||
<img class="affiliate-artwork" id="affiliate-artwork" src="" alt="Album art">
|
||||
<img class="affiliate-artwork" id="affiliate-artwork" src="" alt="Albumcover">
|
||||
<div class="affiliate-info">
|
||||
<div class="affiliate-track" id="affiliate-track-name"></div>
|
||||
<div class="affiliate-artist" id="affiliate-artist-name"></div>
|
||||
<div class="affiliate-album" id="affiliate-album-name"></div>
|
||||
<a class="btn btn-amazon" id="affiliate-amazon-link" href="#" target="_blank" rel="noopener noreferrer">
|
||||
Buy on Amazon Music
|
||||
Bei Amazon Music kaufen
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -48,24 +48,24 @@
|
|||
<!-- ===== TABS ===== -->
|
||||
<div class="tabs" id="tabs">
|
||||
<button class="tab-btn active" onclick="showTab('radio')">Radio</button>
|
||||
<button class="tab-btn" onclick="showTab('focus')">Focus</button>
|
||||
<button class="tab-btn" onclick="showTab('focus')">Fokus</button>
|
||||
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
|
||||
<button class="tab-btn" onclick="showTab('books')">Books</button>
|
||||
<button class="tab-btn" onclick="showTab('books')">Bücher</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== RADIO TAB ===== -->
|
||||
<section class="tab-panel" id="tab-radio">
|
||||
<div class="tabs sub-tabs" id="radio-sub-tabs">
|
||||
<button class="tab-btn" onclick="showRadioTab('search')">Search</button>
|
||||
<button class="tab-btn active" onclick="showRadioTab('saved')">Saved</button>
|
||||
<button class="tab-btn" onclick="showRadioTab('history')">History</button>
|
||||
<button class="tab-btn" onclick="showRadioTab('search')">Suche</button>
|
||||
<button class="tab-btn active" onclick="showRadioTab('saved')">Gespeichert</button>
|
||||
<button class="tab-btn" onclick="showRadioTab('history')">Verlauf</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== SEARCH SUB-PANEL ===== -->
|
||||
<div class="sub-tab-panel" id="tab-search" style="display:none;">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info… or paste a stream address" onkeydown="if(event.key==='Enter') doSearch()">
|
||||
<button class="btn" onclick="doSearch()">Search</button>
|
||||
<input type="text" id="search-input" class="search-input" placeholder="Auf radio-browser.info suchen … oder Stream-Adresse einfügen" onkeydown="if(event.key==='Enter') doSearch()">
|
||||
<button class="btn" onclick="doSearch()">Suchen</button>
|
||||
</div>
|
||||
<div class="mood-chips" id="mood-chips"></div>
|
||||
<div id="curated-lists" class="curated-lists-container"></div>
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Bitrate</th>
|
||||
<th>Country</th>
|
||||
<th>Land</th>
|
||||
<th>Tags</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
<div class="sub-tab-panel" id="tab-saved">
|
||||
{% if featured_stations %}
|
||||
<div class="featured-section">
|
||||
<p class="featured-label">★ Featured</p>
|
||||
<p class="featured-label">★ Empfohlen</p>
|
||||
<ul class="featured-list">
|
||||
{% for s in featured_stations %}
|
||||
<li>
|
||||
|
|
@ -107,8 +107,8 @@
|
|||
<!-- populated by JS -->
|
||||
</div>
|
||||
<div class="import-bar">
|
||||
<label class="btn btn-sm" for="m3u-file-input" title="Import .m3u / .m3u8 from the desktop app">
|
||||
⇧ Import M3U
|
||||
<label class="btn btn-sm" for="m3u-file-input" title=".m3u / .m3u8 aus der Desktop-App importieren">
|
||||
⇧ M3U importieren
|
||||
</label>
|
||||
<input type="file" id="m3u-file-input" accept=".m3u,.m3u8" style="display:none;" onchange="importM3U(this)">
|
||||
<span id="import-status" class="muted"></span>
|
||||
|
|
@ -116,11 +116,11 @@
|
|||
<table class="data-table" id="saved-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th title="Favorite">★</th>
|
||||
<th title="Favorit">★</th>
|
||||
<th>Name</th>
|
||||
<th>Bitrate</th>
|
||||
<th>Country</th>
|
||||
<th title="Notes">✎</th>
|
||||
<th>Land</th>
|
||||
<th title="Notizen">✎</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
|
@ -131,7 +131,7 @@
|
|||
<td>
|
||||
<button class="btn-icon fav-btn {% if station.is_favorite %}active{% endif %}"
|
||||
onclick="toggleFav({{ station.id }})"
|
||||
title="Toggle favorite">★</button>
|
||||
title="Favorit an/aus">★</button>
|
||||
</td>
|
||||
<td class="station-name-cell">{{ station.name }}</td>
|
||||
<td>{{ station.bitrate }}</td>
|
||||
|
|
@ -140,25 +140,25 @@
|
|||
<td>
|
||||
<button class="btn btn-sm"
|
||||
onclick="playStation('{{ station.url }}', '{{ station.name|escapejs }}', {{ station.id }})">
|
||||
▶ Play
|
||||
▶ Abspielen
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="removeStation({{ station.id }})">
|
||||
Remove
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">No saved stations yet.</td></tr>
|
||||
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">Noch keine gespeicherten Sender.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||
to save stations and sync across devices.
|
||||
<a href="{% url 'login' %}">Anmelden</a> oder <a href="{% url 'register' %}">registrieren</a>,
|
||||
um Sender zu speichern und zwischen Geräten abzugleichen.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -168,9 +168,9 @@
|
|||
<table class="data-table" id="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Station</th>
|
||||
<th>Track</th>
|
||||
<th>Zeit</th>
|
||||
<th>Sender</th>
|
||||
<th>Titel</th>
|
||||
<th>♬</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
|
@ -181,11 +181,11 @@
|
|||
<td class="history-time">{{ entry.played_at|slice:":16"|cut:"T" }}</td>
|
||||
<td>{{ entry.station_name }}</td>
|
||||
<td>{{ entry.track }}</td>
|
||||
<td>{% if entry.scrobbled %}<span title="Scrobbled to Last.fm">✓</span>{% endif %}</td>
|
||||
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Remove">✕</button></td>
|
||||
<td>{% if entry.scrobbled %}<span title="An Last.fm gescrobbelt">✓</span>{% endif %}</td>
|
||||
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Entfernen">✕</button></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr id="history-empty-row"><td colspan="5" class="empty-msg">No history yet.</td></tr>
|
||||
<tr id="history-empty-row"><td colspan="5" class="empty-msg">Noch kein Verlauf.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -197,13 +197,13 @@
|
|||
<table class="data-table" id="focus-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Station</th>
|
||||
<th>Duration</th>
|
||||
<th>Zeit</th>
|
||||
<th>Sender</th>
|
||||
<th>Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="focus-tbody">
|
||||
<tr><td colspan="3" class="empty-msg">No focus sessions yet. Start the timer!</td></tr>
|
||||
<tr><td colspan="3" class="empty-msg">Noch keine Fokus-Sitzungen. Starte den Timer!</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
@ -215,8 +215,8 @@
|
|||
<p class="muted dpw-loading">Podcasts werden geladen …</p>
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||
to subscribe to podcasts.
|
||||
<a href="{% url 'login' %}">Anmelden</a> oder <a href="{% url 'register' %}">registrieren</a>,
|
||||
um Podcasts zu abonnieren.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
|
@ -226,7 +226,7 @@
|
|||
{% if user.is_authenticated %}
|
||||
<div id="book-upload-area">
|
||||
<div class="book-drop-zone" id="book-drop-zone">
|
||||
<span>Drop .epub or .pdf here or <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">browse</label></span>
|
||||
<span>.epub oder .pdf hierher ziehen oder <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">auswählen</label></span>
|
||||
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
||||
<span id="book-upload-status" class="muted"></span>
|
||||
</div>
|
||||
|
|
@ -248,8 +248,8 @@
|
|||
<div id="book-list" class="book-list"></div>
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||
to use the encrypted ebook reader.
|
||||
<a href="{% url 'login' %}">Anmelden</a> oder <a href="{% url 'register' %}">registrieren</a>,
|
||||
um den verschlüsselten E-Book-Reader zu nutzen.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
|
@ -263,30 +263,30 @@
|
|||
<input type="text" inputmode="decimal" id="reader-progress-input" class="volume-num" value="0" style="display:none;">
|
||||
<span id="reader-progress-suffix" class="muted"></span>
|
||||
</span>
|
||||
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">⌕</button>
|
||||
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font & layout">⚙</button>
|
||||
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Suchen">⌕</button>
|
||||
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Schrift & Layout">⚙</button>
|
||||
<select id="reader-tts-lang" class="tts-lang-select" title="Vorlese-Sprache" onchange="setTtsLang(this.value)">
|
||||
<option value="de">DE</option>
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<button class="btn-icon" id="reader-tts-btn" onclick="toggleReadAloud()" title="Vorlesen">▶</button>
|
||||
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
||||
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks">▤</button>
|
||||
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>
|
||||
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes & highlights">✎</button>
|
||||
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Lesezeichen setzen">★</button>
|
||||
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Lesezeichen">▤</button>
|
||||
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Inhaltsverzeichnis">≡</button>
|
||||
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notizen & Markierungen">✎</button>
|
||||
<button class="btn-icon marker-mode-btn reader-marker-btn-mobile" id="reader-marker-btn-mobile" onclick="toggleMarkerMode()" title="Text markieren">✒</button>
|
||||
<button class="btn-icon" id="reader-reset-pos-btn" onclick="saveReaderProgress(true)" title="Diese Position als Lesefortschritt setzen (überschreibt gespeicherten Fortschritt)">⚑</button>
|
||||
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)">✕</button>
|
||||
<button class="btn-icon" onclick="closeReader()" title="Schließen (Esc)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reader-body-row" class="reader-body-row">
|
||||
<aside id="reader-margin" class="reader-margin">
|
||||
<div class="reader-margin-header">
|
||||
<span class="reader-margin-title">Notes</span>
|
||||
<span class="reader-margin-title">Notizen</span>
|
||||
<span class="reader-margin-header-actions">
|
||||
<button class="btn-icon marker-mode-btn" id="reader-marker-btn" onclick="toggleMarkerMode()" title="Mark text">✒</button>
|
||||
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="List all highlights & notes, jump to any of them">▦</button>
|
||||
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file">⭳</button>
|
||||
<button class="btn-icon marker-mode-btn" id="reader-marker-btn" onclick="toggleMarkerMode()" title="Text markieren">✒</button>
|
||||
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="Alle Markierungen & Notizen auflisten und hinspringen">▦</button>
|
||||
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Als Textdatei exportieren">⭳</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="reader-margin-markers" class="reader-margin-markers"></div>
|
||||
|
|
@ -300,7 +300,7 @@
|
|||
<aside id="sidebar" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span id="sidebar-title" class="sidebar-title"></span>
|
||||
<button class="btn-icon sidebar-close" onclick="closeSidebar()" title="Close">✕</button>
|
||||
<button class="btn-icon sidebar-close" onclick="closeSidebar()" title="Schließen">✕</button>
|
||||
</div>
|
||||
<div id="sidebar-body" class="sidebar-body"></div>
|
||||
</aside>
|
||||
|
|
@ -311,7 +311,7 @@
|
|||
<p id="modal-message" class="modal-message"></p>
|
||||
<input type="text" id="modal-input" class="search-input modal-input" style="display:none;">
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="modal-cancel-btn" class="btn" style="display:none;">Cancel</button>
|
||||
<button type="button" id="modal-cancel-btn" class="btn" style="display:none;">Abbrechen</button>
|
||||
<button type="button" id="modal-ok-btn" class="btn btn-primary">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
|
@ -80,15 +80,15 @@
|
|||
<div id="track-name"></div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="play-btn">▶ Play</button>
|
||||
<button id="play-btn">▶ Abspielen</button>
|
||||
<div class="vol-wrap">
|
||||
<span>vol</span>
|
||||
<span>Lautst.</span>
|
||||
<input type="range" id="vol-slider" min="0" max="255" value="{{ stream_vol }}">
|
||||
<input type="number" id="vol-num" min="0" max="255" value="{{ stream_vol }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="back-btn" onclick="window.close()">← close tab</button>
|
||||
<button id="back-btn" onclick="window.close()">← Tab schließen</button>
|
||||
|
||||
<script>
|
||||
const audio = new Audio();
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
setVol(parseInt(slider.value, 10));
|
||||
audio.play().then(() => {
|
||||
playing = true;
|
||||
playBtn.innerHTML = '▮▮ Stop';
|
||||
playBtn.innerHTML = '▮▮ Stopp';
|
||||
|
||||
if (sse) sse.close();
|
||||
sse = new EventSource('/radio/sse/?url=' + encodeURIComponent(streamUrl));
|
||||
|
|
@ -137,8 +137,8 @@
|
|||
// Autoplay blocked — reset state, prompt user to click
|
||||
playing = false;
|
||||
audio.src = '';
|
||||
playBtn.innerHTML = '▶ Play';
|
||||
document.getElementById('track-name').textContent = 'Click Play to start';
|
||||
playBtn.innerHTML = '▶ Abspielen';
|
||||
document.getElementById('track-name').textContent = 'Zum Starten auf Abspielen tippen';
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -146,7 +146,7 @@
|
|||
audio.pause();
|
||||
audio.src = '';
|
||||
playing = false;
|
||||
playBtn.innerHTML = '▶ Play';
|
||||
playBtn.innerHTML = '▶ Abspielen';
|
||||
document.getElementById('track-name').textContent = '';
|
||||
document.title = stationName;
|
||||
if (sse) { sse.close(); sse = null; }
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
if (playing) stopPlay(); else startPlay();
|
||||
});
|
||||
|
||||
document.getElementById('track-name').textContent = 'Click Play to start';
|
||||
document.getElementById('track-name').textContent = 'Zum Starten auf Abspielen tippen';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue