From 17e205b6f0101c77b89178a1b3f981013df4e5a3 Mon Sep 17 00:00:00 2001 From: marwin Date: Tue, 4 Aug 2026 09:01:52 +0200 Subject: [PATCH] Reader/UI: mehrere Fixes + Playwright-Setup (SW v21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 8 + diora/settings.py | 2 +- e2e/authenticated.spec.js | 25 +++ e2e/env.js | 18 ++ e2e/global-setup.js | 60 +++++++ e2e/smoke.spec.js | 37 ++++ package-lock.json | 79 +++++++++ package.json | 20 +++ playwright.config.js | 37 ++++ static/css/app.css | 38 +++++ static/js/app.js | 330 ++++++++++++++++++++++++++++++++---- static/js/sw.js | 2 +- templates/radio/player.html | 25 ++- 13 files changed, 636 insertions(+), 45 deletions(-) create mode 100644 e2e/authenticated.spec.js create mode 100644 e2e/env.js create mode 100644 e2e/global-setup.js create mode 100644 e2e/smoke.spec.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js diff --git a/.gitignore b/.gitignore index 1ff60f1..319f27a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/diora/settings.py b/diora/settings.py index 8aa8c36..c28d5f4 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -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}, } } diff --git a/e2e/authenticated.spec.js b/e2e/authenticated.spec.js new file mode 100644 index 0000000..163d8f2 --- /dev/null +++ b/e2e/authenticated.spec.js @@ -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); +}); diff --git a/e2e/env.js b/e2e/env.js new file mode 100644 index 0000000..67164b0 --- /dev/null +++ b/e2e/env.js @@ -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 }; diff --git a/e2e/global-setup.js b/e2e/global-setup.js new file mode 100644 index 0000000..07e636d --- /dev/null +++ b/e2e/global-setup.js @@ -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(); +}; diff --git a/e2e/smoke.spec.js b/e2e/smoke.spec.js new file mode 100644 index 0000000..7bc4b02 --- /dev/null +++ b/e2e/smoke.spec.js @@ -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(); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..feb0427 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ca5a119 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..391972b --- /dev/null +++ b/playwright.config.js @@ -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, + }, +}); diff --git a/static/css/app.css b/static/css/app.css index 7fe08f1..d7246ec 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; diff --git a/static/js/app.js b/static/js/app.js index c4872b2..6fffb29 100644 --- a/static/js/app.js +++ b/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 = ` - You listen to ${escapeHtml(stationName)} a lot — consider supporting them ❤️ + You listen to ${escapeHtml(stationName)} a lot — consider supporting them ♥ `; 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() {
- +
`; @@ -1339,7 +1452,7 @@ function renderEpisodeList(episodes, feedId, container) {
- + @@ -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 = `Weit gescrollt`; + 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 ? '⚠️ wrong key' : ''; + const keyWarning = b.keyOk === false ? '⚠ wrong key' : ''; const broken = _brokenBooks.has(b.id); html += `
@@ -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 = ` - - - + + + @@ -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) - const savedTab = localStorage.getItem('diora_active_tab') || 'radio'; - const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved'; - showTab(!navigator.onLine && IS_AUTHENTICATED ? 'books' : savedTab); - showRadioTab(savedRadioTab); + // 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(offlineOverride ? 'books' : savedTab); + showRadioTab(savedRadioTab); + } // React to connectivity changes window.addEventListener('offline', () => { diff --git a/static/js/sw.js b/static/js/sw.js index 8f11f96..7d2d734 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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', diff --git a/templates/radio/player.html b/templates/radio/player.html index 76fba67..abfc16f 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -18,14 +18,14 @@ - +