From 58ae6b9de6a0c82fc41c5b13da856a30f18137aa Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 29 Jan 2026 08:48:04 +0800 Subject: [PATCH] fix: persist onboarding and banner dismiss state to backend Moves onboarding_completed and dismissed_banners from localStorage to backend settings (settings.json) to survive incognito/private browser modes. Fixes #786 --- py/routes/handlers/misc_handlers.py | 2 + py/services/settings_manager.py | 2 + static/js/core.js | 6 +- static/js/managers/BannerService.js | 73 +++++++++++++++++++++++-- static/js/managers/OnboardingManager.js | 70 +++++++++++++++++++----- 5 files changed, 129 insertions(+), 24 deletions(-) diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index efc7bade..f0b52251 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -231,6 +231,8 @@ class SettingsHandler: "enable_metadata_archive_db", "language", "use_portable_settings", + "onboarding_completed", + "dismissed_banners", "proxy_enabled", "proxy_type", "proxy_host", diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 391b4e9c..bae5be26 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -35,6 +35,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB, "language": "en", "show_only_sfw": False, + "onboarding_completed": False, + "dismissed_banners": [], "enable_metadata_archive_db": False, "proxy_enabled": False, "proxy_host": "", diff --git a/static/js/core.js b/static/js/core.js index b78c07bf..27175114 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -46,7 +46,7 @@ export class AppCore { state.loadingManager = new LoadingManager(); modalManager.initialize(); updateService.initialize(); - bannerService.initialize(); + await bannerService.initialize(); window.modalManager = modalManager; window.settingsManager = settingsManager; const exampleImagesManager = new ExampleImagesManager(); @@ -81,8 +81,8 @@ export class AppCore { this.initialized = true; // Start onboarding if needed (after everything is initialized) - setTimeout(() => { - onboardingManager.start(); + setTimeout(async () => { + await onboardingManager.start(); }, 1000); // Small delay to ensure all elements are rendered // Return the core instance for chaining diff --git a/static/js/managers/BannerService.js b/static/js/managers/BannerService.js index 5b0947a9..94e498b7 100644 --- a/static/js/managers/BannerService.js +++ b/static/js/managers/BannerService.js @@ -36,7 +36,7 @@ class BannerService { /** * Initialize the banner service */ - initialize() { + async initialize() { if (this.initialized) return; this.container = document.getElementById('banner-container'); @@ -45,6 +45,9 @@ class BannerService { return; } + // Load dismissed banners from backend first (for persistence across browser modes) + await this.loadDismissedBannersFromBackend(); + // Register default banners this.registerBanner('civitai-extension', { id: 'civitai-extension', @@ -76,10 +79,36 @@ class BannerService { this.prepareCommunitySupportBanner(); - this.showActiveBanners(); + await this.showActiveBanners(); this.initialized = true; } + /** + * Load dismissed banners from backend settings + * Falls back to localStorage if backend is unavailable + */ + async loadDismissedBannersFromBackend() { + try { + const response = await fetch('/api/lm/settings'); + const data = await response.json(); + if (data.success && data.settings && Array.isArray(data.settings.dismissed_banners)) { + // Merge backend dismissed banners with localStorage + const backendDismissed = data.settings.dismissed_banners; + const localDismissed = getStorageItem('dismissed_banners', []); + + // Use Set to get unique banner IDs + const mergedDismissed = [...new Set([...backendDismissed, ...localDismissed])]; + + // Save merged list to localStorage as cache + if (mergedDismissed.length > 0) { + setStorageItem('dismissed_banners', mergedDismissed); + } + } + } catch (e) { + console.debug('Failed to fetch dismissed banners from backend, using localStorage'); + } + } + /** * Register a new banner * @param {string} id - Unique banner ID @@ -101,6 +130,7 @@ class BannerService { * @returns {boolean} */ isBannerDismissed(bannerId) { + // Check localStorage (which is synced with backend on load) const dismissedBanners = getStorageItem('dismissed_banners', []); return dismissedBanners.includes(bannerId); } @@ -109,13 +139,16 @@ class BannerService { * Dismiss a banner * @param {string} bannerId - Banner ID */ - dismissBanner(bannerId) { + async dismissBanner(bannerId) { const dismissedBanners = getStorageItem('dismissed_banners', []); let bannerAlreadyDismissed = dismissedBanners.includes(bannerId); if (!bannerAlreadyDismissed) { dismissedBanners.push(bannerId); setStorageItem('dismissed_banners', dismissedBanners); + + // Save to backend for persistence (survives incognito/private mode) + await this.saveDismissedBannersToBackend(dismissedBanners); } // Remove banner from DOM @@ -139,10 +172,26 @@ class BannerService { } } + /** + * Save dismissed banners to backend settings + * @param {string[]} dismissedBanners - Array of dismissed banner IDs + */ + async saveDismissedBannersToBackend(dismissedBanners) { + try { + await fetch('/api/lm/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dismissed_banners: dismissedBanners }) + }); + } catch (e) { + console.error('Failed to save dismissed banners to backend:', e); + } + } + /** * Show all active (non-dismissed) banners */ - showActiveBanners() { + async showActiveBanners() { if (!this.container) return; const activeBanners = Array.from(this.banners.values()) @@ -177,7 +226,7 @@ class BannerService { }).join('') : ''; const dismissButtonHtml = banner.dismissible ? - `` : ''; @@ -227,8 +276,20 @@ class BannerService { /** * Clear all dismissed banners (for testing/admin purposes) */ - clearDismissedBanners() { + async clearDismissedBanners() { setStorageItem('dismissed_banners', []); + + // Also clear on backend + try { + await fetch('/api/lm/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dismissed_banners: [] }) + }); + } catch (e) { + console.error('Failed to clear dismissed banners on backend:', e); + } + location.reload(); } diff --git a/static/js/managers/OnboardingManager.js b/static/js/managers/OnboardingManager.js index ecfdf3e7..f5805dfc 100644 --- a/static/js/managers/OnboardingManager.js +++ b/static/js/managers/OnboardingManager.js @@ -82,7 +82,23 @@ export class OnboardingManager { } // Check if user should see onboarding - shouldShowOnboarding() { + // First checks backend settings (persistent), falls back to localStorage + async shouldShowOnboarding() { + // Try to get state from backend first (persistent across browser modes) + try { + const response = await fetch('/api/lm/settings'); + const data = await response.json(); + if (data.success && data.settings && data.settings.onboarding_completed === true) { + // Sync to localStorage as cache + setStorageItem('onboarding_completed', true); + return false; + } + } catch (e) { + // Backend unavailable, fall back to localStorage + console.debug('Failed to fetch onboarding state from backend, using localStorage'); + } + + // Fallback to localStorage (for backward compatibility) const completed = getStorageItem('onboarding_completed'); const skipped = getStorageItem('onboarding_skipped'); return !completed && !skipped; @@ -90,7 +106,8 @@ export class OnboardingManager { // Start the onboarding process async start() { - if (!this.shouldShowOnboarding()) { + const shouldShow = await this.shouldShowOnboarding(); + if (!shouldShow) { return; } @@ -159,9 +176,9 @@ export class OnboardingManager { }); // Handle skip button - skip entire tutorial - document.getElementById('skipLanguageBtn').addEventListener('click', () => { + document.getElementById('skipLanguageBtn').addEventListener('click', async () => { document.body.removeChild(modal); - this.skip(); // Skip entire tutorial instead of just language selection + await this.skip(); // Skip entire tutorial instead of just language selection resolve(); }); @@ -205,11 +222,11 @@ export class OnboardingManager { } // Start the tutorial steps - startTutorial() { + async startTutorial() { this.isActive = true; this.currentStep = 0; this.createOverlay(); - this.showStep(0); + await this.showStep(0); } // Create overlay elements @@ -231,9 +248,9 @@ export class OnboardingManager { } // Show specific step - showStep(stepIndex) { + async showStep(stepIndex) { if (stepIndex >= this.steps.length) { - this.complete(); + await this.complete(); return; } @@ -242,7 +259,7 @@ export class OnboardingManager { if (!target && step.target !== 'body') { // Skip this step if target not found - this.showStep(stepIndex + 1); + await this.showStep(stepIndex + 1); return; } @@ -426,25 +443,48 @@ export class OnboardingManager { } // Navigate to next step - nextStep() { - this.showStep(this.currentStep + 1); + async nextStep() { + await this.showStep(this.currentStep + 1); } // Navigate to previous step - previousStep() { + async previousStep() { if (this.currentStep > 0) { - this.showStep(this.currentStep - 1); + await this.showStep(this.currentStep - 1); } } // Skip the tutorial - skip() { + async skip() { + // Save to backend for persistence (survives incognito/private mode) + try { + await fetch('/api/lm/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ onboarding_completed: true }) + }); + } catch (e) { + console.error('Failed to save onboarding state to backend:', e); + } + // Also save to localStorage as cache and for backward compatibility setStorageItem('onboarding_skipped', true); + setStorageItem('onboarding_completed', true); this.cleanup(); } // Complete the tutorial - complete() { + async complete() { + // Save to backend for persistence (survives incognito/private mode) + try { + await fetch('/api/lm/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ onboarding_completed: true }) + }); + } catch (e) { + console.error('Failed to save onboarding state to backend:', e); + } + // Also save to localStorage as cache and for backward compatibility setStorageItem('onboarding_completed', true); this.cleanup(); }