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",
"language",
"use_portable_settings",
"onboarding_completed",
"dismissed_banners",
"proxy_enabled",
"proxy_type",
"proxy_host",

View File

@@ -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": "",

View File

@@ -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

View File

@@ -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 ?
`<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>
</button>` : '';
@@ -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();
}

View File

@@ -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();
}