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
This commit is contained in:
Will Miao
2026-01-29 08:48:04 +08:00
parent ee25643f68
commit 58ae6b9de6
5 changed files with 129 additions and 24 deletions

View File

@@ -231,6 +231,8 @@ class SettingsHandler:
"enable_metadata_archive_db", "enable_metadata_archive_db",
"language", "language",
"use_portable_settings", "use_portable_settings",
"onboarding_completed",
"dismissed_banners",
"proxy_enabled", "proxy_enabled",
"proxy_type", "proxy_type",
"proxy_host", "proxy_host",

View File

@@ -35,6 +35,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB, "hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB,
"language": "en", "language": "en",
"show_only_sfw": False, "show_only_sfw": False,
"onboarding_completed": False,
"dismissed_banners": [],
"enable_metadata_archive_db": False, "enable_metadata_archive_db": False,
"proxy_enabled": False, "proxy_enabled": False,
"proxy_host": "", "proxy_host": "",

View File

@@ -46,7 +46,7 @@ export class AppCore {
state.loadingManager = new LoadingManager(); state.loadingManager = new LoadingManager();
modalManager.initialize(); modalManager.initialize();
updateService.initialize(); updateService.initialize();
bannerService.initialize(); await bannerService.initialize();
window.modalManager = modalManager; window.modalManager = modalManager;
window.settingsManager = settingsManager; window.settingsManager = settingsManager;
const exampleImagesManager = new ExampleImagesManager(); const exampleImagesManager = new ExampleImagesManager();
@@ -81,8 +81,8 @@ export class AppCore {
this.initialized = true; this.initialized = true;
// Start onboarding if needed (after everything is initialized) // Start onboarding if needed (after everything is initialized)
setTimeout(() => { setTimeout(async () => {
onboardingManager.start(); await onboardingManager.start();
}, 1000); // Small delay to ensure all elements are rendered }, 1000); // Small delay to ensure all elements are rendered
// Return the core instance for chaining // Return the core instance for chaining

View File

@@ -36,7 +36,7 @@ class BannerService {
/** /**
* Initialize the banner service * Initialize the banner service
*/ */
initialize() { async initialize() {
if (this.initialized) return; if (this.initialized) return;
this.container = document.getElementById('banner-container'); this.container = document.getElementById('banner-container');
@@ -45,6 +45,9 @@ class BannerService {
return; return;
} }
// Load dismissed banners from backend first (for persistence across browser modes)
await this.loadDismissedBannersFromBackend();
// Register default banners // Register default banners
this.registerBanner('civitai-extension', { this.registerBanner('civitai-extension', {
id: 'civitai-extension', id: 'civitai-extension',
@@ -76,10 +79,36 @@ class BannerService {
this.prepareCommunitySupportBanner(); this.prepareCommunitySupportBanner();
this.showActiveBanners(); await this.showActiveBanners();
this.initialized = true; 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 * Register a new banner
* @param {string} id - Unique banner ID * @param {string} id - Unique banner ID
@@ -101,6 +130,7 @@ class BannerService {
* @returns {boolean} * @returns {boolean}
*/ */
isBannerDismissed(bannerId) { isBannerDismissed(bannerId) {
// Check localStorage (which is synced with backend on load)
const dismissedBanners = getStorageItem('dismissed_banners', []); const dismissedBanners = getStorageItem('dismissed_banners', []);
return dismissedBanners.includes(bannerId); return dismissedBanners.includes(bannerId);
} }
@@ -109,13 +139,16 @@ class BannerService {
* Dismiss a banner * Dismiss a banner
* @param {string} bannerId - Banner ID * @param {string} bannerId - Banner ID
*/ */
dismissBanner(bannerId) { async dismissBanner(bannerId) {
const dismissedBanners = getStorageItem('dismissed_banners', []); const dismissedBanners = getStorageItem('dismissed_banners', []);
let bannerAlreadyDismissed = dismissedBanners.includes(bannerId); let bannerAlreadyDismissed = dismissedBanners.includes(bannerId);
if (!bannerAlreadyDismissed) { if (!bannerAlreadyDismissed) {
dismissedBanners.push(bannerId); dismissedBanners.push(bannerId);
setStorageItem('dismissed_banners', dismissedBanners); setStorageItem('dismissed_banners', dismissedBanners);
// Save to backend for persistence (survives incognito/private mode)
await this.saveDismissedBannersToBackend(dismissedBanners);
} }
// Remove banner from DOM // 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 * Show all active (non-dismissed) banners
*/ */
showActiveBanners() { async showActiveBanners() {
if (!this.container) return; if (!this.container) return;
const activeBanners = Array.from(this.banners.values()) const activeBanners = Array.from(this.banners.values())
@@ -177,7 +226,7 @@ class BannerService {
}).join('') : ''; }).join('') : '';
const dismissButtonHtml = banner.dismissible ? const dismissButtonHtml = banner.dismissible ?
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}')" title="Dismiss"> `<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}').catch(console.error)" title="Dismiss">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button>` : ''; </button>` : '';
@@ -227,8 +276,20 @@ class BannerService {
/** /**
* Clear all dismissed banners (for testing/admin purposes) * Clear all dismissed banners (for testing/admin purposes)
*/ */
clearDismissedBanners() { async clearDismissedBanners() {
setStorageItem('dismissed_banners', []); 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(); location.reload();
} }

View File

@@ -82,7 +82,23 @@ export class OnboardingManager {
} }
// Check if user should see onboarding // 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 completed = getStorageItem('onboarding_completed');
const skipped = getStorageItem('onboarding_skipped'); const skipped = getStorageItem('onboarding_skipped');
return !completed && !skipped; return !completed && !skipped;
@@ -90,7 +106,8 @@ export class OnboardingManager {
// Start the onboarding process // Start the onboarding process
async start() { async start() {
if (!this.shouldShowOnboarding()) { const shouldShow = await this.shouldShowOnboarding();
if (!shouldShow) {
return; return;
} }
@@ -159,9 +176,9 @@ export class OnboardingManager {
}); });
// Handle skip button - skip entire tutorial // Handle skip button - skip entire tutorial
document.getElementById('skipLanguageBtn').addEventListener('click', () => { document.getElementById('skipLanguageBtn').addEventListener('click', async () => {
document.body.removeChild(modal); 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(); resolve();
}); });
@@ -205,11 +222,11 @@ export class OnboardingManager {
} }
// Start the tutorial steps // Start the tutorial steps
startTutorial() { async startTutorial() {
this.isActive = true; this.isActive = true;
this.currentStep = 0; this.currentStep = 0;
this.createOverlay(); this.createOverlay();
this.showStep(0); await this.showStep(0);
} }
// Create overlay elements // Create overlay elements
@@ -231,9 +248,9 @@ export class OnboardingManager {
} }
// Show specific step // Show specific step
showStep(stepIndex) { async showStep(stepIndex) {
if (stepIndex >= this.steps.length) { if (stepIndex >= this.steps.length) {
this.complete(); await this.complete();
return; return;
} }
@@ -242,7 +259,7 @@ export class OnboardingManager {
if (!target && step.target !== 'body') { if (!target && step.target !== 'body') {
// Skip this step if target not found // Skip this step if target not found
this.showStep(stepIndex + 1); await this.showStep(stepIndex + 1);
return; return;
} }
@@ -426,25 +443,48 @@ export class OnboardingManager {
} }
// Navigate to next step // Navigate to next step
nextStep() { async nextStep() {
this.showStep(this.currentStep + 1); await this.showStep(this.currentStep + 1);
} }
// Navigate to previous step // Navigate to previous step
previousStep() { async previousStep() {
if (this.currentStep > 0) { if (this.currentStep > 0) {
this.showStep(this.currentStep - 1); await this.showStep(this.currentStep - 1);
} }
} }
// Skip the tutorial // 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_skipped', true);
setStorageItem('onboarding_completed', true);
this.cleanup(); this.cleanup();
} }
// Complete the tutorial // 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); setStorageItem('onboarding_completed', true);
this.cleanup(); this.cleanup();
} }