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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
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;
|
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;
|
||||||
|
|
|
||||||
324
static/js/app.js
324
static/js/app.js
|
|
@ -82,6 +82,70 @@ function escapeHtml(str) {
|
||||||
.replace(/"/g, '"');
|
.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
|
// 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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,14 @@
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ 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">⏪ 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>
|
<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 ⏩</button>
|
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30 »</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>★</th>
|
<th title="Favorite">★</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 & layout">⚙</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-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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue