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() {