From 7d311451cb884de7d22135681fa5496ca4c623ec Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:44:36 -0700 Subject: [PATCH] feat: URL-based deep linking for SPA navigation - Flask catch-all route serves index.html for client-side paths, excluding api/static/auth/callback/status prefixes.- navigateToPage pushes history state so URL reflects current page.- popstate listener handles browser back/forward without reloading.- Initial load reads window.location to restore the page after refresh or direct link.- artist-detail and playlist-explorer fall back to parent pages since they need runtime context. --- web_server.py | 9 ++++++++- webui/static/script.js | 45 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/web_server.py b/web_server.py index 8133fe86..348bfb28 100644 --- a/web_server.py +++ b/web_server.py @@ -21,7 +21,7 @@ from pathlib import Path from urllib.parse import urljoin from concurrent.futures import ThreadPoolExecutor, as_completed -from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g +from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g, abort from flask_socketio import SocketIO, emit, join_room, leave_room from utils.logging_config import get_logger, setup_logging from utils.async_helpers import run_async @@ -4875,6 +4875,13 @@ def run_detection(server_type): def index(): return render_template('index.html') +@app.route('/') +def spa_catch_all(page): + # Serve index.html for client-side routes; let Flask handle real routes first. + if page.startswith(('api/', 'static/', 'auth/', 'callback', 'tidal/', 'status')): + abort(404) + return render_template('index.html') + # --- API Endpoints --- # Tracks cumulative item-processed totals over time for windowed counting. diff --git a/webui/static/script.js b/webui/static/script.js index 81c84b60..710aacce 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -2773,6 +2773,31 @@ function initializeNavigation() { navigateToPage(page); }); }); + + window.addEventListener('popstate', (event) => { + const page = (event.state && event.state.page) || _getPageFromPath(); + if (page && page !== currentPage) { + navigateToPage(page, { skipPushState: true }); + } + }); +} + +const _DEEPLINK_VALID_PAGES = new Set([ + 'dashboard', 'sync', 'downloads', 'discover', 'artists', 'automations', + 'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist', + 'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer', + 'hydrabase', 'tools' +]); + +function _getPageFromPath() { + const path = window.location.pathname.replace(/^\/+|\/+$/g, ''); + if (!path) return 'dashboard'; + const basePage = path.split('/')[0]; + if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard'; + // Context-dependent pages fall back to a sensible parent + if (basePage === 'artist-detail') return 'artists'; + if (basePage === 'playlist-explorer') return 'library'; + return basePage; } // =============================== @@ -2905,7 +2930,7 @@ function initializeDownloadManagerToggle() { console.log('Download manager toggle initialized'); } -function navigateToPage(pageId) { +function navigateToPage(pageId, options = {}) { if (pageId === currentPage) return; // Permission guard — redirect to home page if not allowed @@ -2937,6 +2962,13 @@ function navigateToPage(pageId) { currentPage = pageId; + if (!options.skipPushState) { + const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId; + if (window.location.pathname !== urlPath) { + history.pushState({ page: pageId }, '', urlPath); + } + } + // Show/hide global search bar (hide on downloads page where enhanced search exists) if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility(); @@ -10233,8 +10265,15 @@ async function loadInitialData() { // Navigate to user's home page (or dashboard for admin) const homePage = getProfileHomePage(); - if (homePage !== 'dashboard') { - navigateToPage(homePage); + const urlPage = _getPageFromPath(); + const targetPage = (urlPage && urlPage !== 'dashboard' && isPageAllowed(urlPage)) + ? urlPage + : homePage; + + history.replaceState({ page: targetPage }, '', (targetPage === 'dashboard' ? '/' : '/' + targetPage) + window.location.search + window.location.hash); + + if (targetPage !== 'dashboard') { + navigateToPage(targetPage, { skipPushState: true }); } else { await loadDashboardData(); loadDashboardSyncHistory();