Reader/UI: mehrere Fixes + Playwright-Setup (SW v21)
All checks were successful
Build and push Docker image / build (push) Successful in 55s
Test / test (push) Successful in 15s

- Books: Reader sprang beim Scroll+sofortigem Tap auf die alte Position
  zurück (Anker-Tracking lief nur alle 2s über den Save-Debounce, jetzt
  eigener 150ms-Tracker unabhängig davon)
- Books: +/- Buttons neben den Reader-Slidern (Font/Zeilenhöhe/Breite)
- Books: Sicherheitsnetz-Toast bei plötzlichen großen Scroll-Sprüngen
  (Fling, aus Versehen 'G' gedrückt) mit Zurückspringen-Option; bewusste
  Sprünge (TOC, Lesezeichen, Fortschritts-Eingabe, Suche) lösen ihn nicht aus
- Radio: Donation-Hinweis kam bei vielen Lieblingssendern gefühlt ständig —
  jetzt zusätzlich auf ~1-von-10 Plays gedrosselt
- UI: farbige Emoji-Icons (🔍📻💡 etc.) durch monochrome Unicode-Symbole
  ersetzt, die nicht mehr je nach OS/Browser-Font unterschiedlich aussehen;
  Lesezeichen-Icon war zudem fast identisch zum TOC-Icon
- UI: native alert()/confirm()/prompt() durch eigenes Modal-System ersetzt
- UI: Hash-basiertes URL-Routing für Tabs (#radio/saved, #podcasts/feeds, …)
  — Browser-Zurück/Vor und Deep-Links funktionieren jetzt
- Tests: Playwright e2e-Setup gegen isolierte Test-DB (DIORA_DB_NAME),
  Smoke- und Auth-Specs

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-08-04 09:01:52 +02:00
parent daffd0002b
commit 17e205b6f0
13 changed files with 636 additions and 45 deletions

8
.gitignore vendored
View file

@ -36,3 +36,11 @@ Thumbs.db
antennapod-feeds-2026-03-19.opml antennapod-feeds-2026-03-19.opml
.gitignore .gitignore
playlist.m3u playlist.m3u
# Playwright / Node
node_modules/
test-results/
playwright-report/
blob-report/
e2e/.auth/
data/e2e_test.sqlite3

View file

@ -73,7 +73,7 @@ WSGI_APPLICATION = 'diora.wsgi.application'
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data' / 'db.sqlite3', 'NAME': BASE_DIR / 'data' / os.environ.get('DIORA_DB_NAME', 'db.sqlite3'),
'OPTIONS': {'timeout': 20}, 'OPTIONS': {'timeout': 20},
} }
} }

25
e2e/authenticated.spec.js Normal file
View file

@ -0,0 +1,25 @@
// Authenticated smoke tests: reuse the storage state saved in global-setup.js
// for the fixed TEST_USER, so no login step is needed per test.
const { test, expect } = require('@playwright/test');
const path = require('path');
const { TEST_USER } = require('./env');
test.use({ storageState: path.join(__dirname, '.auth', 'user.json') });
test('logged-in home page shows the username and settings link', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.navbar-user')).toHaveText(TEST_USER.username);
await expect(page.locator('a[href="/accounts/settings/"]')).toBeVisible();
});
test('settings page is reachable while authenticated', async ({ page }) => {
await page.goto('/accounts/settings/');
await expect(page).toHaveURL(/\/accounts\/settings\//);
});
test('book list API requires auth and returns JSON for the logged-in user', async ({ request }) => {
const res = await request.get('/books/');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
});

18
e2e/env.js Normal file
View file

@ -0,0 +1,18 @@
// Shared constants between playwright.config.js and e2e/global-setup.js.
const PORT = 8000;
const BASE_URL = `http://127.0.0.1:${PORT}`;
const SERVER_ENV = {
...process.env,
DIORA_DB_NAME: 'e2e_test.sqlite3',
SECRET_KEY: 'e2e-test-secret-key',
DEBUG: 'True',
ALLOWED_HOSTS: 'localhost 127.0.0.1',
};
const TEST_USER = {
username: 'e2e_test_user',
password: 'e2e-test-pw-12345',
};
module.exports = { PORT, BASE_URL, SERVER_ENV, TEST_USER };

60
e2e/global-setup.js Normal file
View file

@ -0,0 +1,60 @@
// Runs once before the e2e suite, after Playwright's webServer is already up
// (see playwright.config.js) but before any test executes.
//
// 1. Resets and migrates a throwaway SQLite DB (data/e2e_test.sqlite3) so
// tests never touch the real dev database.
// 2. Creates a fixed test user.
// 3. Logs that user in through the real login form and saves the resulting
// storage state, so authenticated specs can start already logged in via
// `test.use({ storageState: 'e2e/.auth/user.json' })`.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { chromium } = require('@playwright/test');
const { BASE_URL, SERVER_ENV, TEST_USER } = require('./env');
const ROOT = path.join(__dirname, '..');
const DB_PATH = path.join(ROOT, 'data', SERVER_ENV.DIORA_DB_NAME);
const AUTH_DIR = path.join(__dirname, '.auth');
const AUTH_FILE = path.join(AUTH_DIR, 'user.json');
module.exports = async function globalSetup() {
// Fresh DB every run.
fs.rmSync(DB_PATH, { force: true });
execFileSync('python3', ['manage.py', 'migrate', '--noinput'], {
cwd: ROOT,
env: SERVER_ENV,
stdio: 'inherit',
});
execFileSync(
'python3',
[
'manage.py',
'shell',
'-c',
`
from django.contrib.auth import get_user_model
User = get_user_model()
User.objects.filter(username=${JSON.stringify(TEST_USER.username)}).delete()
User.objects.create_user(${JSON.stringify(TEST_USER.username)}, "", ${JSON.stringify(TEST_USER.password)})
`,
],
{ cwd: ROOT, env: SERVER_ENV, stdio: 'inherit' }
);
fs.mkdirSync(AUTH_DIR, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ baseURL: BASE_URL });
await page.goto(`${BASE_URL}/accounts/login/`);
await page.fill('[name=username]', TEST_USER.username);
await page.fill('[name=password]', TEST_USER.password);
await Promise.all([
page.waitForURL(BASE_URL + '/'),
page.click('button[type=submit]'),
]);
await page.context().storageState({ path: AUTH_FILE });
await browser.close();
};

37
e2e/smoke.spec.js Normal file
View file

@ -0,0 +1,37 @@
// Unauthenticated smoke tests: no storageState, run as an anonymous visitor.
const { test, expect } = require('@playwright/test');
test('home page loads for an anonymous visitor', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/diora/);
await expect(page.locator('.navbar-brand')).toHaveText('diora');
await expect(page.locator('.navbar-links a[href="/accounts/login/"]')).toBeVisible();
});
test('login page renders the auth form', async ({ page }) => {
await page.goto('/accounts/login/');
await expect(page.locator('[name=username]')).toBeVisible();
await expect(page.locator('[name=password]')).toBeVisible();
});
test('a new user can register and lands on the home page logged in', async ({ page }) => {
const username = `e2e_reg_${Date.now()}`;
await page.goto('/accounts/register/');
await page.fill('[name=username]', username);
await page.fill('[name=password1]', 'a-very-unlikely-pw-98234');
await page.fill('[name=password2]', 'a-very-unlikely-pw-98234');
await Promise.all([
page.waitForURL('/'),
page.click('button[type=submit]'),
]);
await expect(page.locator('.navbar-user')).toHaveText(username);
});
test('login with wrong credentials shows an error and stays on the login page', async ({ page }) => {
await page.goto('/accounts/login/');
await page.fill('[name=username]', 'nobody-such-user');
await page.fill('[name=password]', 'wrong-password');
await page.click('button[type=submit]');
await expect(page).toHaveURL(/\/accounts\/login\//);
await expect(page.locator('.form-errors, .field-errors')).toBeVisible();
});

79
package-lock.json generated Normal file
View file

@ -0,0 +1,79 @@
{
"name": "diora-web",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "diora-web",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.62.1"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "diora-web",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"repository": {
"type": "git",
"url": "ssh://git@fg.creamfresh.xyz:2222/mrwnslz/diora-web.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.62.1"
}
}

37
playwright.config.js Normal file
View file

@ -0,0 +1,37 @@
// Playwright config for diora's e2e smoke tests.
//
// Tests run against a real `manage.py runserver` instance backed by its own
// throwaway SQLite database (DIORA_DB_NAME), so they never touch the dev
// database at data/db.sqlite3. See e2e/global-setup.js for how that DB is
// migrated and seeded with a test user.
const path = require('path');
const { defineConfig, devices } = require('@playwright/test');
const { BASE_URL, SERVER_ENV } = require('./e2e/env');
module.exports = defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
reporter: [['list']],
timeout: 30_000,
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
globalSetup: require.resolve('./e2e/global-setup.js'),
webServer: {
command: 'python3 manage.py runserver 127.0.0.1:8000 --noreload',
url: BASE_URL + '/accounts/login/',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
cwd: path.join(__dirname),
env: SERVER_ENV,
},
});

View file

@ -1354,6 +1354,39 @@ body.dnd-mode .timer-display {
line-height: 1.6; line-height: 1.6;
} }
/* ===== MODAL DIALOG ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 500;
}
.modal-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--surface, #111);
border: 1px solid var(--border, #333);
border-radius: var(--radius);
padding: 20px;
width: 360px;
max-width: calc(100vw - 32px);
z-index: 501;
}
.modal-message {
margin: 0 0 16px;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-input { width: 100%; margin-bottom: 16px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* Style links and basic HTML inside shownotes */ /* Style links and basic HTML inside shownotes */
.sidebar-body a { color: var(--accent, #e63946); } .sidebar-body a { color: var(--accent, #e63946); }
.sidebar-body p { margin: 0 0 10px; } .sidebar-body p { margin: 0 0 10px; }
@ -1779,6 +1812,11 @@ mark.reader-search-match.active { background:rgba(230,57,70,.7); }
.reader-toast { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); background:var(--fg); color:var(--bg); padding:6px 14px; border-radius:var(--radius); font-size:13px; z-index:600; animation:toast-fade 2s ease forwards; pointer-events:none; } .reader-toast { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); background:var(--fg); color:var(--bg); padding:6px 14px; border-radius:var(--radius); font-size:13px; z-index:600; animation:toast-fade 2s ease forwards; pointer-events:none; }
@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} } @keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} }
.reader-toast-action { display:flex; align-items:center; gap:10px; padding:8px 8px 8px 14px; animation:toast-slide-up 0.2s ease forwards; pointer-events:auto; }
.reader-toast-btn { background:var(--accent,#e63946); color:#fff; border:none; border-radius:calc(var(--radius) - 2px); padding:5px 12px; font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; }
.reader-toast-btn:hover { filter:brightness(1.1); }
@keyframes toast-slide-up { 0%{opacity:0; transform:translate(-50%, 8px);} 100%{opacity:1; transform:translate(-50%, 0);} }
.build-time { .build-time {
position: fixed; position: fixed;
bottom: 4px; bottom: 4px;

View file

@ -82,6 +82,70 @@ function escapeHtml(str) {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
// ---------------------------------------------------------------------------
// Modal dialogs — styled replacements for native alert()/confirm()/prompt(),
// which look jarring against the rest of the custom dark UI and can't be
// styled or (in confirm/prompt's case) awaited without blocking the thread.
// All three return a Promise; callers must be async and `await` them.
// ---------------------------------------------------------------------------
function _showModal({ message, showInput = false, inputValue = '', showCancel = false, okLabel = 'OK', danger = false }) {
return new Promise((resolve) => {
const overlay = $('modal-overlay');
const dialog = $('modal-dialog');
const msgEl = $('modal-message');
const inputEl = $('modal-input');
const okBtn = $('modal-ok-btn');
const cancelBtn = $('modal-cancel-btn');
if (!overlay || !dialog) { resolve(null); return; }
msgEl.textContent = message;
inputEl.style.display = showInput ? '' : 'none';
inputEl.value = inputValue;
cancelBtn.style.display = showCancel ? '' : 'none';
okBtn.textContent = okLabel;
okBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
overlay.style.display = '';
dialog.style.display = '';
(showInput ? inputEl : okBtn).focus();
if (showInput) inputEl.select();
function cleanup(result) {
overlay.style.display = 'none';
dialog.style.display = 'none';
overlay.removeEventListener('click', onCancel);
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
document.removeEventListener('keydown', onKeydown);
resolve(result);
}
function onOk() { cleanup(showInput ? inputEl.value : true); }
function onCancel() { cleanup(showInput ? null : false); }
function onKeydown(e) {
if (e.key === 'Escape') onCancel();
if (e.key === 'Enter' && showInput) onOk();
}
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
overlay.addEventListener('click', onCancel);
document.addEventListener('keydown', onKeydown);
});
}
function customAlert(message) {
return _showModal({ message });
}
function customConfirm(message, { danger = false } = {}) {
return _showModal({ message, showCancel: true, danger });
}
function customPrompt(message, defaultValue = '') {
return _showModal({ message, showInput: true, inputValue: defaultValue, showCancel: true });
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Play / Stop // Play / Stop
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -552,7 +616,7 @@ async function saveStation(station) {
}); });
if (res.status === 401) { if (res.status === 401) {
alert('Please log in to save stations.'); await customAlert('Please log in to save stations.');
return; return;
} }
@ -870,14 +934,14 @@ document.addEventListener('keydown', e => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const MOOD_TAGS = [ const MOOD_TAGS = [
{ label: '🎯 Focus', tag: 'ambient' }, { label: 'Focus', tag: 'ambient' },
{ label: 'Lo-fi', tag: 'lofi' }, { label: 'Lo-fi', tag: 'lofi' },
{ label: '🎷 Jazz', tag: 'jazz' }, { label: 'Jazz', tag: 'jazz' },
{ label: '🎻 Classical', tag: 'classical' }, { label: 'Classical', tag: 'classical' },
{ label: '🌧 Ambient', tag: 'ambient' }, { label: 'Ambient', tag: 'ambient' },
{ label: '🤘 Metal', tag: 'metal' }, { label: 'Metal', tag: 'metal' },
{ label: '🎉 Electronic', tag: 'electronic' }, { label: 'Electronic', tag: 'electronic' },
{ label: '📻 Talk', tag: 'talk' }, { label: 'Talk', tag: 'talk' },
]; ];
function initMoodChips() { function initMoodChips() {
@ -903,7 +967,7 @@ function initMoodChips() {
const CURATED_LISTS = [ const CURATED_LISTS = [
{ {
id: 'focus', id: 'focus',
label: '🎯 Focus', label: 'Focus',
stations: [ stations: [
{ name: 'SomaFM Drone Zone', url: 'https://ice6.somafm.com/dronezone-256-mp3' }, { name: 'SomaFM Drone Zone', url: 'https://ice6.somafm.com/dronezone-256-mp3' },
{ name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac' }, { name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac' },
@ -913,7 +977,7 @@ const CURATED_LISTS = [
}, },
{ {
id: 'lofi', id: 'lofi',
label: 'Lo-fi / Chill', label: 'Lo-fi / Chill',
stations: [ stations: [
{ name: 'SomaFM Groove Salad Classic', url: 'https://ice6.somafm.com/gsclassic-128-mp3' }, { name: 'SomaFM Groove Salad Classic', url: 'https://ice6.somafm.com/gsclassic-128-mp3' },
{ name: 'SomaFM Secret Agent', url: 'https://ice4.somafm.com/secretagent-128-mp3' }, { name: 'SomaFM Secret Agent', url: 'https://ice4.somafm.com/secretagent-128-mp3' },
@ -922,7 +986,7 @@ const CURATED_LISTS = [
}, },
{ {
id: 'dark', id: 'dark',
label: '🌑 Dark / Industrial', label: 'Dark / Industrial',
stations: [ stations: [
{ name: 'SomaFM Doomed', url: 'https://ice2.somafm.com/doomed-256-mp3' }, { name: 'SomaFM Doomed', url: 'https://ice2.somafm.com/doomed-256-mp3' },
{ name: 'Nightride FM Darksynth', url: 'https://stream.nightride.fm/darksynth.mp3' }, { name: 'Nightride FM Darksynth', url: 'https://stream.nightride.fm/darksynth.mp3' },
@ -931,7 +995,7 @@ const CURATED_LISTS = [
}, },
{ {
id: 'classical', id: 'classical',
label: '🎻 Classical', label: 'Classical',
stations: [ stations: [
{ name: 'BR Klassik', url: 'https://dispatcher.rndfnk.com/br/brklassik/live/mp3/high' }, { 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' }, { name: 'SWR Kultur', url: 'https://f111.rndfnk.com/ard/swr/swr2/live/mp3/256/stream.mp3?aggregator=web' },
@ -993,13 +1057,18 @@ function maybeShowDonationHint(stationUrl, stationName) {
const last = parseInt(localStorage.getItem(key) || '0', 10); const last = parseInt(localStorage.getItem(key) || '0', 10);
if (Date.now() - last < DONATION_HINT_COOLDOWN_MS) return; if (Date.now() - last < DONATION_HINT_COOLDOWN_MS) return;
// Past the threshold just means "this station is a regular" — from then on
// every single play would otherwise qualify, so only actually show the hint
// on roughly 1 in 10 of those plays instead of every time.
if (Math.random() >= 0.1) return;
const existing = document.getElementById('donation-hint'); const existing = document.getElementById('donation-hint');
if (existing) existing.remove(); if (existing) existing.remove();
const el = document.createElement('div'); const el = document.createElement('div');
el.id = 'donation-hint'; el.id = 'donation-hint';
el.innerHTML = ` el.innerHTML = `
<span>You listen to <strong>${escapeHtml(stationName)}</strong> a lot consider supporting them </span> <span>You listen to <strong>${escapeHtml(stationName)}</strong> a lot consider supporting them </span>
<button onclick="dismissDonationHint('${escapeAttr(stationUrl)}')" title="Dismiss"></button> <button onclick="dismissDonationHint('${escapeAttr(stationUrl)}')" title="Dismiss"></button>
`; `;
document.body.appendChild(el); document.body.appendChild(el);
@ -1017,8 +1086,8 @@ function dismissDonationHint(stationUrl) {
// Station notes // Station notes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function editNotes(pk, current) { async function editNotes(pk, current) {
const note = prompt('Station note:', current || ''); const note = await customPrompt('Station note:', current || '');
if (note === null) return; // cancelled if (note === null) return; // cancelled
fetch(`/radio/notes/${pk}/`, { fetch(`/radio/notes/${pk}/`, {
method: 'POST', method: 'POST',
@ -1038,6 +1107,45 @@ function editNotes(pk, current) {
const TOP_TABS = ['radio', 'focus', 'podcasts', 'books']; const TOP_TABS = ['radio', 'focus', 'podcasts', 'books'];
const RADIO_SUB_TABS = ['search', 'saved', 'history']; const RADIO_SUB_TABS = ['search', 'saved', 'history'];
const PODCAST_ROUTABLE_VIEWS = ['search', 'feeds', 'inbox', 'queue'];
// ---------------------------------------------------------------------------
// URL hash routing — makes tabs deep-linkable/shareable and lets the browser
// back/forward buttons move between them. Deliberately hash-based rather
// than real paths: /podcasts/ and /books/ etc. are already taken by the
// JSON API, so this avoids touching Django's urls.py at all.
// ---------------------------------------------------------------------------
let _routingFromHash = false;
let _currentTopTab = 'radio';
function _syncHash() {
if (_routingFromHash) return;
let hash = _currentTopTab;
if (_currentTopTab === 'radio') {
hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved');
} else if (_currentTopTab === 'podcasts') {
hash += '/' + (podcastCurrentView || 'feeds');
}
if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash);
}
function _routeFromHash() {
const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean);
if (!TOP_TABS.includes(tab)) return;
_routingFromHash = true;
try {
if (tab === 'podcasts' && PODCAST_ROUTABLE_VIEWS.includes(sub)) {
podcastCurrentView = sub;
}
showTab(tab);
if (tab === 'radio' && RADIO_SUB_TABS.includes(sub)) showRadioTab(sub);
} finally {
_routingFromHash = false;
}
}
window.addEventListener('popstate', _routeFromHash);
window.addEventListener('hashchange', _routeFromHash);
function showOfflineBanner(offline) { function showOfflineBanner(offline) {
document.body.classList.toggle('app-offline', offline); document.body.classList.toggle('app-offline', offline);
@ -1056,6 +1164,8 @@ function showTab(name) {
}); });
localStorage.setItem('diora_active_tab', name); localStorage.setItem('diora_active_tab', name);
_currentTopTab = name;
_syncHash();
if (name === 'podcasts') loadPodcastTab(); if (name === 'podcasts') loadPodcastTab();
if (name === 'books') loadBookList(); if (name === 'books') loadBookList();
@ -1072,6 +1182,7 @@ function showRadioTab(name) {
}); });
localStorage.setItem('diora_active_radio_tab', name); localStorage.setItem('diora_active_radio_tab', name);
_syncHash();
if (name === 'saved') loadRecommendations(); if (name === 'saved') loadRecommendations();
} }
@ -1094,6 +1205,8 @@ function showPodcastView(view) {
if (el) el.style.display = (p === view) ? '' : 'none'; if (el) el.style.display = (p === view) ? '' : 'none';
}); });
if (PODCAST_ROUTABLE_VIEWS.includes(view)) _syncHash();
if (view === 'feeds') renderFeedList(); if (view === 'feeds') renderFeedList();
if (view === 'inbox') loadAndRenderInbox(); if (view === 'inbox') loadAndRenderInbox();
if (view === 'queue') loadAndRenderQueue(); if (view === 'queue') loadAndRenderQueue();
@ -1140,8 +1253,8 @@ function podcastSearchOpen() {
showPodcastView('search'); showPodcastView('search');
} }
function addFeedByUrl() { async function addFeedByUrl() {
const url = prompt('RSS feed URL:'); const url = await customPrompt('RSS feed URL:');
if (url) subscribeFeed(url.trim(), ''); if (url) subscribeFeed(url.trim(), '');
} }
@ -1200,7 +1313,7 @@ function renderFeedList() {
<div class="podcast-feed-actions"> <div class="podcast-feed-actions">
<button class="btn btn-sm" onclick="openFeed(${feed.id})">Episodes</button> <button class="btn btn-sm" onclick="openFeed(${feed.id})">Episodes</button>
<button class="btn btn-sm" onclick="refreshFeed(${feed.id})" title="Refresh feed"></button> <button class="btn btn-sm" onclick="refreshFeed(${feed.id})" title="Refresh feed"></button>
<button class="btn btn-sm ${feed.auto_queue ? 'active' : ''}" onclick="toggleFeedAutoQueue(${feed.id}, this)" title="${feed.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes'}">Q</button> <button class="btn btn-sm ${feed.auto_queue ? 'active' : ''}" onclick="toggleFeedAutoQueue(${feed.id}, this)" title="${feed.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes'}">Auto</button>
<button class="btn btn-sm btn-danger" onclick="removeFeed(${feed.id})">Remove</button> <button class="btn btn-sm btn-danger" onclick="removeFeed(${feed.id})">Remove</button>
</div> </div>
`; `;
@ -1339,7 +1452,7 @@ function renderEpisodeList(episodes, feedId, container) {
</div> </div>
<div class="episode-actions"> <div class="episode-actions">
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})"></button> <button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})"></button>
<button class="btn btn-sm" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">📋</button> <button class="btn btn-sm" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">Notes</button>
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${ep.in_queue ? 'In queue' : 'Add to queue'}">${ep.in_queue ? '✓Q' : '+Q'}</button> <button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${ep.in_queue ? 'In queue' : 'Add to queue'}">${ep.in_queue ? '✓Q' : '+Q'}</button>
<button class="btn btn-sm" onclick="toggleMarkPlayed(${ep.id}, this)" title="Mark played">${ep.played ? '✓' : '○'}</button> <button class="btn btn-sm" onclick="toggleMarkPlayed(${ep.id}, this)" title="Mark played">${ep.played ? '✓' : '○'}</button>
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download"></button> <button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download"></button>
@ -2013,7 +2126,7 @@ async function refreshAllFeeds() {
} }
async function removeFeed(feedId) { async function removeFeed(feedId) {
if (!confirm('Remove this podcast?')) return; if (!await customConfirm('Remove this podcast?', { danger: true })) return;
try { try {
await fetch(`/podcasts/feeds/${feedId}/remove/`, { await fetch(`/podcasts/feeds/${feedId}/remove/`, {
method: 'POST', method: 'POST',
@ -2052,7 +2165,7 @@ async function importOPML(input) {
async function downloadEpisode(url, title, btn) { async function downloadEpisode(url, title, btn) {
if (!('caches' in window)) { if (!('caches' in window)) {
alert('Cache API not supported in this browser.'); await customAlert('Cache API not supported in this browser.');
return; return;
} }
@ -2072,7 +2185,7 @@ async function downloadEpisode(url, title, btn) {
if (btn) { btn.textContent = '✓'; btn.disabled = false; } if (btn) { btn.textContent = '✓'; btn.disabled = false; }
} catch (e) { } catch (e) {
if (btn) { btn.textContent = origText; btn.disabled = false; } if (btn) { btn.textContent = origText; btn.disabled = false; }
alert('Download failed: ' + e.message); await customAlert('Download failed: ' + e.message);
} }
} }
@ -2390,11 +2503,11 @@ async function uploadBackground(file) {
if (!file) return; if (!file) return;
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
alert('Only JPEG, PNG, or WebP images are allowed.'); await customAlert('Only JPEG, PNG, or WebP images are allowed.');
return; return;
} }
if (file.size > DIORA_CONFIG.bgMaxBytes) { if (file.size > DIORA_CONFIG.bgMaxBytes) {
alert(`Image must be ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB or smaller.`); await customAlert(`Image must be ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB or smaller.`);
return; return;
} }
@ -2658,6 +2771,82 @@ let _resizeObserver = null;
let _currentPositionAnchor = ''; let _currentPositionAnchor = '';
const bookMetaCache = {}; // id → {title, author, type} const bookMetaCache = {}; // id → {title, author, type}
// ---------------------------------------------------------------------------
// Reader "scrolled too far" safety net — offers a toast to jump back after a
// sudden, large scroll (accidental fling, fat-fingered keybind, etc.). Only
// watches organic scrolling; explicit destination jumps (TOC, bookmarks, the
// progress input, search) call _suppressScrollJumpDetect() first so they
// don't trigger it.
// ---------------------------------------------------------------------------
let _scrollRestTop = null; // scrollTop last "settled" at, before the current burst
let _scrollRestDebounce = null;
let _scrollJumpSuppressed = false; // true while an explicit/programmatic jump is in flight
let _scrollJumpToastEl = null;
// Marks the current (and any immediately-following, possibly animated) scroll
// as deliberate, so _checkScrollJump ignores it. Suppression lifts itself
// once scrolling actually settles (see _armScrollJumpSettle) — not after a
// fixed delay — so it stays active for however long a `smooth` scroll
// animation to a far-away target actually takes, and calling this again
// (e.g. from repeated resize-triggered corrections while a book settles)
// simply pushes that settle point further out instead of racing it.
function _suppressScrollJumpDetect() {
_scrollJumpSuppressed = true;
_armScrollJumpSettle();
}
function _armScrollJumpSettle() {
clearTimeout(_scrollRestDebounce);
_scrollRestDebounce = setTimeout(() => {
const contentEl = $('reader-content');
if (contentEl) _scrollRestTop = contentEl.scrollTop;
_scrollJumpSuppressed = false;
}, 500);
}
function _dismissScrollJumpToast() {
if (_scrollJumpToastEl) {
_scrollJumpToastEl.remove();
_scrollJumpToastEl = null;
}
}
function _showScrollJumpToast(backTop) {
_dismissScrollJumpToast();
const toast = document.createElement('div');
toast.className = 'reader-toast reader-toast-action';
toast.innerHTML = `<span>Weit gescrollt</span><button type="button" class="reader-toast-btn">Zurückspringen</button>`;
document.body.appendChild(toast);
_scrollJumpToastEl = toast;
toast.querySelector('.reader-toast-btn').addEventListener('click', () => {
const contentEl = $('reader-content');
if (contentEl) {
_suppressScrollJumpDetect();
contentEl.scrollTo({top: backTop, behavior: 'smooth'});
}
_dismissScrollJumpToast();
});
setTimeout(_dismissScrollJumpToast, 6000);
}
function _checkScrollJump(contentEl) {
const top = contentEl.scrollTop;
if (_scrollRestTop === null) _scrollRestTop = top; // first sample this session
if (!_scrollJumpSuppressed && !_scrollJumpToastEl) {
const screen = contentEl.clientHeight || 1;
if (Math.abs(top - _scrollRestTop) > screen * 3.5) {
_showScrollJumpToast(_scrollRestTop);
}
}
// Only adopt the new position as the "settled" baseline — and lift any
// suppression — once scrolling has paused for a bit. That's what lets a
// fast, still-in-progress fling (or a long smooth-scroll animation) be
// compared against/protected until it actually finishes, not mid-flight.
_armScrollJumpSettle();
}
const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))'; const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))';
function getPositionAnchor(contentEl) { function getPositionAnchor(contentEl) {
@ -3127,7 +3316,7 @@ function repairBook(bookId) {
openBook(bookId); openBook(bookId);
} catch (e) { } catch (e) {
if (btn) btn.textContent = '!'; if (btn) btn.textContent = '!';
alert('Reparatur fehlgeschlagen: ' + e.message); await customAlert('Reparatur fehlgeschlagen: ' + e.message);
} }
}; };
input.click(); input.click();
@ -3139,7 +3328,7 @@ function renderBookList(books) {
let html = ''; let html = '';
for (const b of books) { for (const b of books) {
const pct = Math.round((b.scroll_fraction || 0) * 100); 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="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠ wrong key</span>' : '';
const broken = _brokenBooks.has(b.id); const broken = _brokenBooks.has(b.id);
html += `<div class="book-item" data-book-id="${b.id}"> html += `<div class="book-item" data-book-id="${b.id}">
<div class="book-item-info"> <div class="book-item-info">
@ -3572,6 +3761,7 @@ async function openBook(bookId) {
} }
progressInput.addEventListener('change', function () { progressInput.addEventListener('change', function () {
_suppressScrollJumpDetect();
if (isPdf) { if (isPdf) {
const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1)); const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1));
this.value = page; this.value = page;
@ -3591,6 +3781,7 @@ async function openBook(bookId) {
} }
// Restore scroll position — must happen BEFORE auto-save timer is started // Restore scroll position — must happen BEFORE auto-save timer is started
_suppressScrollJumpDetect();
try { try {
let fraction = 0, anchor = ''; let fraction = 0, anchor = '';
try { try {
@ -3632,6 +3823,11 @@ async function openBook(bookId) {
} }
} catch (e) {} } catch (e) {}
// Prime the "settled" baseline to wherever we just restored to, rather
// than waiting to lazily pick up whatever scroll event happens to fire
// first — that could otherwise be a later, unrelated layout correction.
_scrollRestTop = contentEl.scrollTop;
// Update progress input on scroll // Update progress input on scroll
contentEl.addEventListener('scroll', () => { contentEl.addEventListener('scroll', () => {
if (!progressInput) return; if (!progressInput) return;
@ -3661,16 +3857,42 @@ async function openBook(bookId) {
_scrollDebounce = setTimeout(saveReaderProgress, 2000); _scrollDebounce = setTimeout(saveReaderProgress, 2000);
}, {passive: true}); }, {passive: true});
// Keep _currentPositionAnchor fresh independent of the slower save debounce
// above: the resize observer below (or an immersive-bars toggle right after
// scrolling) would otherwise restore to a stale, pre-scroll anchor.
if (!isPdf) {
let _anchorTrackDebounce = null;
contentEl.addEventListener('scroll', () => {
clearTimeout(_anchorTrackDebounce);
_anchorTrackDebounce = setTimeout(() => {
_currentPositionAnchor = getPositionAnchor(contentEl);
}, 150);
}, {passive: true});
}
// Restore anchor on viewport resize (e.g. screen rotation, font zoom) // Restore anchor on viewport resize (e.g. screen rotation, font zoom)
if (!isPdf) { if (!isPdf) {
_resizeObserver = new ResizeObserver(() => { _resizeObserver = new ResizeObserver(() => {
if (_currentPositionAnchor) { if (_currentPositionAnchor) {
_suppressScrollJumpDetect();
requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor)); requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor));
} }
}); });
_resizeObserver.observe(contentEl); _resizeObserver.observe(contentEl);
} }
// Offer to jump back after a sudden, large scroll (accidental fling,
// fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above.
_scrollRestTop = null;
let _scrollJumpRaf = null;
contentEl.addEventListener('scroll', () => {
if (_scrollJumpRaf) return;
_scrollJumpRaf = requestAnimationFrame(() => {
_scrollJumpRaf = null;
_checkScrollJump(contentEl);
});
}, {passive: true});
enterReaderImmersiveMode(); enterReaderImmersiveMode();
} catch (e) { } catch (e) {
@ -3722,7 +3944,7 @@ function showImportKey() {
} }
async function deleteBook(bookId) { async function deleteBook(bookId) {
if (!confirm('Delete this book? This cannot be undone.')) return; if (!await customConfirm('Delete this book? This cannot be undone.', { danger: true })) return;
try { try {
const res = await fetch(`/books/${bookId}/delete/`, { const res = await fetch(`/books/${bookId}/delete/`, {
method: 'POST', method: 'POST',
@ -3869,6 +4091,7 @@ function openTocSidebar() {
function jumpToTocEntry(href) { function jumpToTocEntry(href) {
closeSidebar(); closeSidebar();
_suppressScrollJumpDetect();
setTimeout(() => { setTimeout(() => {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
@ -3944,6 +4167,7 @@ function applyReaderSettings(isPdf) {
contentEl.style.fontFamily = fontMap[readerSettings.fontFamily] || fontMap.serif; contentEl.style.fontFamily = fontMap[readerSettings.fontFamily] || fontMap.serif;
contentEl.classList.toggle('reader-no-bold', !!readerSettings.noBold); contentEl.classList.toggle('reader-no-bold', !!readerSettings.noBold);
if (_currentPositionAnchor && currentBookId) { if (_currentPositionAnchor && currentBookId) {
_suppressScrollJumpDetect();
requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor)); requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor));
} }
} }
@ -3979,9 +4203,9 @@ function toggleSettingsPanel() {
if (!isPdf) { if (!isPdf) {
panel.innerHTML = ` panel.innerHTML = `
<label>Font <input type="range" id="rs-font" min="12" max="24" step="1" value="${readerSettings.fontSize}"> <span id="rs-font-val">${readerSettings.fontSize}px</span></label> <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 <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <span id="rs-line-val">${readerSettings.lineHeight}</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 <input type="range" id="rs-width" min="40" max="90" step="5" value="${readerSettings.maxWidth}"> <span id="rs-width-val">${readerSettings.maxWidth}ch</span></label> <label>Width <button class="btn btn-sm" id="rs-width-minus"></button> <input type="range" id="rs-width" min="40" max="90" step="5" value="${readerSettings.maxWidth}"> <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> <button class="btn btn-sm" id="rs-width-full">Full</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'serif' ? 'active' : ''}" data-rs-font="serif">Serif</button> <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 === 'sans' ? 'active' : ''}" data-rs-font="sans">Sans</button>
@ -4011,6 +4235,14 @@ function toggleSettingsPanel() {
applyReaderSettings(false); applyReaderSettings(false);
saveReaderSettings(); saveReaderSettings();
}); });
panel.querySelector('#rs-font-minus').addEventListener('click', () => {
fontRange.value = Math.max(+fontRange.min, parseInt(fontRange.value, 10) - 1);
fontRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-font-plus').addEventListener('click', () => {
fontRange.value = Math.min(+fontRange.max, parseInt(fontRange.value, 10) + 1);
fontRange.dispatchEvent(new Event('input'));
});
const lineRange = panel.querySelector('#rs-line'); const lineRange = panel.querySelector('#rs-line');
const lineVal = panel.querySelector('#rs-line-val'); const lineVal = panel.querySelector('#rs-line-val');
@ -4020,6 +4252,14 @@ function toggleSettingsPanel() {
applyReaderSettings(false); applyReaderSettings(false);
saveReaderSettings(); saveReaderSettings();
}); });
panel.querySelector('#rs-line-minus').addEventListener('click', () => {
lineRange.value = Math.max(+lineRange.min, parseInt(lineRange.value, 10) - 1);
lineRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-line-plus').addEventListener('click', () => {
lineRange.value = Math.min(+lineRange.max, parseInt(lineRange.value, 10) + 1);
lineRange.dispatchEvent(new Event('input'));
});
const widthRange = panel.querySelector('#rs-width'); const widthRange = panel.querySelector('#rs-width');
const widthVal = panel.querySelector('#rs-width-val'); const widthVal = panel.querySelector('#rs-width-val');
@ -4029,6 +4269,14 @@ function toggleSettingsPanel() {
applyReaderSettings(false); applyReaderSettings(false);
saveReaderSettings(); saveReaderSettings();
}); });
panel.querySelector('#rs-width-minus').addEventListener('click', () => {
widthRange.value = Math.max(+widthRange.min, parseInt(widthRange.value, 10) - 5);
widthRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-width-plus').addEventListener('click', () => {
widthRange.value = Math.min(+widthRange.max, parseInt(widthRange.value, 10) + 5);
widthRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-width-full').addEventListener('click', () => { panel.querySelector('#rs-width-full').addEventListener('click', () => {
readerSettings.maxWidth = 999; readerSettings.maxWidth = 999;
@ -4472,6 +4720,7 @@ function jumpToBookmark(id) {
const bm = currentBookmarks.find(b => b.id === id); const bm = currentBookmarks.find(b => b.id === id);
if (!bm) return; if (!bm) return;
closeSidebar(); closeSidebar();
_suppressScrollJumpDetect();
setTimeout(() => { setTimeout(() => {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
@ -4648,6 +4897,7 @@ function clearReaderSearch() {
function scrollToSearchMatch(idx) { function scrollToSearchMatch(idx) {
if (!searchMatches.length) return; if (!searchMatches.length) return;
_suppressScrollJumpDetect();
searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx)); searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx));
searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'}); searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
const countEl = document.getElementById('rs-search-count'); const countEl = document.getElementById('rs-search-count');
@ -5161,11 +5411,19 @@ function openRadioSidebar() {
// Init book drop zone // Init book drop zone
initBookDropZone(); initBookDropZone();
// Restore last active tab — when offline, always go to books (most useful) // Restore active tab: a URL hash (shared/bookmarked link, or browser
// back/forward) wins over the last-used tab remembered in localStorage —
// except when offline, where books (usable offline) always takes over.
const offlineOverride = !navigator.onLine && IS_AUTHENTICATED;
const hashTab = (location.hash || '').replace(/^#/, '').split('/')[0];
if (!offlineOverride && TOP_TABS.includes(hashTab)) {
_routeFromHash();
} else {
const savedTab = localStorage.getItem('diora_active_tab') || 'radio'; const savedTab = localStorage.getItem('diora_active_tab') || 'radio';
const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved'; const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved';
showTab(!navigator.onLine && IS_AUTHENTICATED ? 'books' : savedTab); showTab(offlineOverride ? 'books' : savedTab);
showRadioTab(savedRadioTab); showRadioTab(savedRadioTab);
}
// React to connectivity changes // React to connectivity changes
window.addEventListener('offline', () => { window.addEventListener('offline', () => {

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use. * diora service worker caches the app shell for offline use.
*/ */
const CACHE = 'diora-v15'; const CACHE = 'diora-v21';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',

View file

@ -18,14 +18,14 @@
</label> </label>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button> <button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</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="Focus mode (hides UI, press Esc to exit)"></button>
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">📻</button> <button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio"></button>
</div> </div>
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;"> <div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">&thinsp;15</button> <button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">«&thinsp;15</button>
<span class="seek-time" id="seek-current">0:00</span> <span class="seek-time" id="seek-current">0:00</span>
<input type="range" id="seek-slider" class="seek-slider" min="0" max="100" value="0"> <input type="range" id="seek-slider" class="seek-slider" min="0" max="100" value="0">
<span class="seek-time" id="seek-duration">0:00</span> <span class="seek-time" id="seek-duration">0:00</span>
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30&thinsp;</button> <button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30&thinsp;»</button>
<div class="speed-btns" id="speed-btns"> <div class="speed-btns" id="speed-btns">
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button> <button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button>
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button> <button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
@ -43,7 +43,7 @@
<button class="btn-icon" id="timer-toggle-btn" onclick="toggleTimer()" title="Start/pause timer"></button> <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-reset-btn" onclick="resetTimer()" title="Reset timer"></button>
<span class="focus-today" id="focus-today-widget" style="display:none;"></span> <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="Toggle black background"></button>
</div> </div>
</section> </section>
@ -131,7 +131,7 @@
<table class="data-table" id="saved-table"> <table class="data-table" id="saved-table">
<thead> <thead>
<tr> <tr>
<th>&#9733;</th> <th title="Favorite">&#9733;</th>
<th>Name</th> <th>Name</th>
<th>Bitrate</th> <th>Bitrate</th>
<th>Country</th> <th>Country</th>
@ -338,10 +338,10 @@
<input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;"> <input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;">
<span id="reader-progress-suffix" class="muted"></span> <span id="reader-progress-suffix" class="muted"></span>
</span> </span>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">🔍</button> <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 &amp; layout"></button> <button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button>
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></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-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-toc-btn" onclick="openTocSidebar()" title="Table of contents"></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" 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="Close (Esc)"></button>
@ -360,6 +360,17 @@
<div id="sidebar-body" class="sidebar-body"></div> <div id="sidebar-body" class="sidebar-body"></div>
</aside> </aside>
<!-- ===== MODAL DIALOG (replaces native alert/confirm/prompt) ===== -->
<div id="modal-overlay" class="modal-overlay" style="display:none;"></div>
<div id="modal-dialog" class="modal-dialog" style="display:none;" role="alertdialog" aria-modal="true">
<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-ok-btn" class="btn btn-primary">OK</button>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}