Reader/UI: mehrere Fixes + Playwright-Setup (SW v21)
- 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:
parent
daffd0002b
commit
17e205b6f0
13 changed files with 636 additions and 45 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -36,3 +36,11 @@ Thumbs.db
|
|||
antennapod-feeds-2026-03-19.opml
|
||||
.gitignore
|
||||
playlist.m3u
|
||||
|
||||
# Playwright / Node
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
e2e/.auth/
|
||||
data/e2e_test.sqlite3
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ WSGI_APPLICATION = 'diora.wsgi.application'
|
|||
DATABASES = {
|
||||
'default': {
|
||||
'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},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
e2e/authenticated.spec.js
Normal file
25
e2e/authenticated.spec.js
Normal 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
18
e2e/env.js
Normal 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
60
e2e/global-setup.js
Normal 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
37
e2e/smoke.spec.js
Normal 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
79
package-lock.json
generated
Normal 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
20
package.json
Normal 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
37
playwright.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1354,6 +1354,39 @@ body.dnd-mode .timer-display {
|
|||
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 */
|
||||
.sidebar-body a { color: var(--accent, #e63946); }
|
||||
.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; }
|
||||
@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 {
|
||||
position: fixed;
|
||||
bottom: 4px;
|
||||
|
|
|
|||
324
static/js/app.js
324
static/js/app.js
|
|
@ -82,6 +82,70 @@ function escapeHtml(str) {
|
|||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -552,7 +616,7 @@ async function saveStation(station) {
|
|||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
alert('Please log in to save stations.');
|
||||
await customAlert('Please log in to save stations.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -870,14 +934,14 @@ document.addEventListener('keydown', e => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOOD_TAGS = [
|
||||
{ label: '🎯 Focus', tag: 'ambient' },
|
||||
{ label: '☕ Lo-fi', tag: 'lofi' },
|
||||
{ label: '🎷 Jazz', tag: 'jazz' },
|
||||
{ label: '🎻 Classical', tag: 'classical' },
|
||||
{ label: '🌧 Ambient', tag: 'ambient' },
|
||||
{ label: '🤘 Metal', tag: 'metal' },
|
||||
{ label: '🎉 Electronic', tag: 'electronic' },
|
||||
{ label: '📻 Talk', tag: 'talk' },
|
||||
{ label: 'Focus', tag: 'ambient' },
|
||||
{ label: 'Lo-fi', tag: 'lofi' },
|
||||
{ label: 'Jazz', tag: 'jazz' },
|
||||
{ label: 'Classical', tag: 'classical' },
|
||||
{ label: 'Ambient', tag: 'ambient' },
|
||||
{ label: 'Metal', tag: 'metal' },
|
||||
{ label: 'Electronic', tag: 'electronic' },
|
||||
{ label: 'Talk', tag: 'talk' },
|
||||
];
|
||||
|
||||
function initMoodChips() {
|
||||
|
|
@ -903,7 +967,7 @@ function initMoodChips() {
|
|||
const CURATED_LISTS = [
|
||||
{
|
||||
id: 'focus',
|
||||
label: '🎯 Focus',
|
||||
label: 'Focus',
|
||||
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' },
|
||||
|
|
@ -913,7 +977,7 @@ const CURATED_LISTS = [
|
|||
},
|
||||
{
|
||||
id: 'lofi',
|
||||
label: '☕ Lo-fi / Chill',
|
||||
label: 'Lo-fi / Chill',
|
||||
stations: [
|
||||
{ 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' },
|
||||
|
|
@ -922,7 +986,7 @@ const CURATED_LISTS = [
|
|||
},
|
||||
{
|
||||
id: 'dark',
|
||||
label: '🌑 Dark / Industrial',
|
||||
label: 'Dark / Industrial',
|
||||
stations: [
|
||||
{ name: 'SomaFM Doomed', url: 'https://ice2.somafm.com/doomed-256-mp3' },
|
||||
{ name: 'Nightride FM Darksynth', url: 'https://stream.nightride.fm/darksynth.mp3' },
|
||||
|
|
@ -931,7 +995,7 @@ const CURATED_LISTS = [
|
|||
},
|
||||
{
|
||||
id: 'classical',
|
||||
label: '🎻 Classical',
|
||||
label: 'Classical',
|
||||
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' },
|
||||
|
|
@ -993,13 +1057,18 @@ function maybeShowDonationHint(stationUrl, stationName) {
|
|||
const last = parseInt(localStorage.getItem(key) || '0', 10);
|
||||
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');
|
||||
if (existing) existing.remove();
|
||||
|
||||
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>
|
||||
<span>You listen to <strong>${escapeHtml(stationName)}</strong> a lot — consider supporting them ♥</span>
|
||||
<button onclick="dismissDonationHint('${escapeAttr(stationUrl)}')" title="Dismiss">✕</button>
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
|
|
@ -1017,8 +1086,8 @@ function dismissDonationHint(stationUrl) {
|
|||
// Station notes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function editNotes(pk, current) {
|
||||
const note = prompt('Station note:', current || '');
|
||||
async function editNotes(pk, current) {
|
||||
const note = await customPrompt('Station note:', current || '');
|
||||
if (note === null) return; // cancelled
|
||||
fetch(`/radio/notes/${pk}/`, {
|
||||
method: 'POST',
|
||||
|
|
@ -1038,6 +1107,45 @@ function editNotes(pk, current) {
|
|||
|
||||
const TOP_TABS = ['radio', 'focus', 'podcasts', 'books'];
|
||||
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) {
|
||||
document.body.classList.toggle('app-offline', offline);
|
||||
|
|
@ -1056,6 +1164,8 @@ function showTab(name) {
|
|||
});
|
||||
|
||||
localStorage.setItem('diora_active_tab', name);
|
||||
_currentTopTab = name;
|
||||
_syncHash();
|
||||
|
||||
if (name === 'podcasts') loadPodcastTab();
|
||||
if (name === 'books') loadBookList();
|
||||
|
|
@ -1072,6 +1182,7 @@ function showRadioTab(name) {
|
|||
});
|
||||
|
||||
localStorage.setItem('diora_active_radio_tab', name);
|
||||
_syncHash();
|
||||
|
||||
if (name === 'saved') loadRecommendations();
|
||||
}
|
||||
|
|
@ -1094,6 +1205,8 @@ function showPodcastView(view) {
|
|||
if (el) el.style.display = (p === view) ? '' : 'none';
|
||||
});
|
||||
|
||||
if (PODCAST_ROUTABLE_VIEWS.includes(view)) _syncHash();
|
||||
|
||||
if (view === 'feeds') renderFeedList();
|
||||
if (view === 'inbox') loadAndRenderInbox();
|
||||
if (view === 'queue') loadAndRenderQueue();
|
||||
|
|
@ -1140,8 +1253,8 @@ function podcastSearchOpen() {
|
|||
showPodcastView('search');
|
||||
}
|
||||
|
||||
function addFeedByUrl() {
|
||||
const url = prompt('RSS feed URL:');
|
||||
async function addFeedByUrl() {
|
||||
const url = await customPrompt('RSS feed URL:');
|
||||
if (url) subscribeFeed(url.trim(), '');
|
||||
}
|
||||
|
||||
|
|
@ -1200,7 +1313,7 @@ function renderFeedList() {
|
|||
<div class="podcast-feed-actions">
|
||||
<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 ${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>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1339,7 +1452,7 @@ function renderEpisodeList(episodes, feedId, container) {
|
|||
</div>
|
||||
<div class="episode-actions">
|
||||
<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="toggleMarkPlayed(${ep.id}, this)" title="Mark played">${ep.played ? '✓' : '○'}</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) {
|
||||
if (!confirm('Remove this podcast?')) return;
|
||||
if (!await customConfirm('Remove this podcast?', { danger: true })) return;
|
||||
try {
|
||||
await fetch(`/podcasts/feeds/${feedId}/remove/`, {
|
||||
method: 'POST',
|
||||
|
|
@ -2052,7 +2165,7 @@ async function importOPML(input) {
|
|||
|
||||
async function downloadEpisode(url, title, btn) {
|
||||
if (!('caches' in window)) {
|
||||
alert('Cache API not supported in this browser.');
|
||||
await customAlert('Cache API not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2072,7 +2185,7 @@ async function downloadEpisode(url, title, btn) {
|
|||
if (btn) { btn.textContent = '✓'; btn.disabled = false; }
|
||||
} catch (e) {
|
||||
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;
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -2658,6 +2771,82 @@ let _resizeObserver = null;
|
|||
let _currentPositionAnchor = '';
|
||||
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(*))';
|
||||
|
||||
function getPositionAnchor(contentEl) {
|
||||
|
|
@ -3127,7 +3316,7 @@ function repairBook(bookId) {
|
|||
openBook(bookId);
|
||||
} catch (e) {
|
||||
if (btn) btn.textContent = '!';
|
||||
alert('Reparatur fehlgeschlagen: ' + e.message);
|
||||
await customAlert('Reparatur fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
|
|
@ -3139,7 +3328,7 @@ function renderBookList(books) {
|
|||
let html = '';
|
||||
for (const b of books) {
|
||||
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);
|
||||
html += `<div class="book-item" data-book-id="${b.id}">
|
||||
<div class="book-item-info">
|
||||
|
|
@ -3572,6 +3761,7 @@ async function openBook(bookId) {
|
|||
}
|
||||
|
||||
progressInput.addEventListener('change', function () {
|
||||
_suppressScrollJumpDetect();
|
||||
if (isPdf) {
|
||||
const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1));
|
||||
this.value = page;
|
||||
|
|
@ -3591,6 +3781,7 @@ async function openBook(bookId) {
|
|||
}
|
||||
|
||||
// Restore scroll position — must happen BEFORE auto-save timer is started
|
||||
_suppressScrollJumpDetect();
|
||||
try {
|
||||
let fraction = 0, anchor = '';
|
||||
try {
|
||||
|
|
@ -3632,6 +3823,11 @@ async function openBook(bookId) {
|
|||
}
|
||||
} 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
|
||||
contentEl.addEventListener('scroll', () => {
|
||||
if (!progressInput) return;
|
||||
|
|
@ -3661,16 +3857,42 @@ async function openBook(bookId) {
|
|||
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
|
||||
}, {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)
|
||||
if (!isPdf) {
|
||||
_resizeObserver = new ResizeObserver(() => {
|
||||
if (_currentPositionAnchor) {
|
||||
_suppressScrollJumpDetect();
|
||||
requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor));
|
||||
}
|
||||
});
|
||||
_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();
|
||||
|
||||
} catch (e) {
|
||||
|
|
@ -3722,7 +3944,7 @@ function showImportKey() {
|
|||
}
|
||||
|
||||
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 {
|
||||
const res = await fetch(`/books/${bookId}/delete/`, {
|
||||
method: 'POST',
|
||||
|
|
@ -3869,6 +4091,7 @@ function openTocSidebar() {
|
|||
|
||||
function jumpToTocEntry(href) {
|
||||
closeSidebar();
|
||||
_suppressScrollJumpDetect();
|
||||
setTimeout(() => {
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
|
|
@ -3944,6 +4167,7 @@ function applyReaderSettings(isPdf) {
|
|||
contentEl.style.fontFamily = fontMap[readerSettings.fontFamily] || fontMap.serif;
|
||||
contentEl.classList.toggle('reader-no-bold', !!readerSettings.noBold);
|
||||
if (_currentPositionAnchor && currentBookId) {
|
||||
_suppressScrollJumpDetect();
|
||||
requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor));
|
||||
}
|
||||
}
|
||||
|
|
@ -3979,9 +4203,9 @@ function toggleSettingsPanel() {
|
|||
|
||||
if (!isPdf) {
|
||||
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>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>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>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="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 ${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>
|
||||
|
|
@ -4011,6 +4235,14 @@ function toggleSettingsPanel() {
|
|||
applyReaderSettings(false);
|
||||
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 lineVal = panel.querySelector('#rs-line-val');
|
||||
|
|
@ -4020,6 +4252,14 @@ function toggleSettingsPanel() {
|
|||
applyReaderSettings(false);
|
||||
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 widthVal = panel.querySelector('#rs-width-val');
|
||||
|
|
@ -4029,6 +4269,14 @@ function toggleSettingsPanel() {
|
|||
applyReaderSettings(false);
|
||||
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', () => {
|
||||
readerSettings.maxWidth = 999;
|
||||
|
|
@ -4472,6 +4720,7 @@ function jumpToBookmark(id) {
|
|||
const bm = currentBookmarks.find(b => b.id === id);
|
||||
if (!bm) return;
|
||||
closeSidebar();
|
||||
_suppressScrollJumpDetect();
|
||||
setTimeout(() => {
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
|
|
@ -4648,6 +4897,7 @@ function clearReaderSearch() {
|
|||
|
||||
function scrollToSearchMatch(idx) {
|
||||
if (!searchMatches.length) return;
|
||||
_suppressScrollJumpDetect();
|
||||
searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx));
|
||||
searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
const countEl = document.getElementById('rs-search-count');
|
||||
|
|
@ -5161,11 +5411,19 @@ function openRadioSidebar() {
|
|||
// Init book drop zone
|
||||
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 savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved';
|
||||
showTab(!navigator.onLine && IS_AUTHENTICATED ? 'books' : savedTab);
|
||||
showTab(offlineOverride ? 'books' : savedTab);
|
||||
showRadioTab(savedRadioTab);
|
||||
}
|
||||
|
||||
// React to connectivity changes
|
||||
window.addEventListener('offline', () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* 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 SHELL = [
|
||||
'/static/css/app.css',
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@
|
|||
</label>
|
||||
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ 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="focus-station-btn" onclick="openRadioSidebar()" title="Radio">📻</button>
|
||||
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button>
|
||||
</div>
|
||||
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
|
||||
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">⏪ 15</button>
|
||||
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">« 15</button>
|
||||
<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">
|
||||
<span class="seek-time" id="seek-duration">0:00</span>
|
||||
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30 ⏩</button>
|
||||
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30 »</button>
|
||||
<div class="speed-btns" id="speed-btns">
|
||||
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</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-reset-btn" onclick="resetTimer()" title="Reset timer">↺</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="Toggle black background">☼</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -131,7 +131,7 @@
|
|||
<table class="data-table" id="saved-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>★</th>
|
||||
<th title="Favorite">★</th>
|
||||
<th>Name</th>
|
||||
<th>Bitrate</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;">
|
||||
<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-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-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-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>
|
||||
|
|
@ -360,6 +360,17 @@
|
|||
<div id="sidebar-body" class="sidebar-body"></div>
|
||||
</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue