mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 13:42:12 -03:00
Moves onboarding_completed and dismissed_banners from localStorage to backend settings (settings.json) to survive incognito/private browser modes. Fixes #786
526 lines
21 KiB
JavaScript
526 lines
21 KiB
JavaScript
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
|
import { state } from '../state/index.js';
|
|
import { translate } from '../utils/i18nHelpers.js';
|
|
import { showToast } from '../utils/uiHelpers.js';
|
|
|
|
export class OnboardingManager {
|
|
constructor() {
|
|
this.isActive = false;
|
|
this.currentStep = 0;
|
|
this.selectedLanguage = 'en'; // Will be updated from state
|
|
this.overlay = null;
|
|
this.spotlight = null;
|
|
this.popup = null;
|
|
this.currentTarget = null; // Track current highlighted element
|
|
|
|
// Available languages with SVG flags (using flag-icons)
|
|
this.languages = [
|
|
{ code: 'en', name: 'English', flag: 'us' },
|
|
{ code: 'zh-CN', name: '简体中文', flag: 'cn' },
|
|
{ code: 'zh-TW', name: '繁體中文', flag: 'hk' },
|
|
{ code: 'ja', name: '日本語', flag: 'jp' },
|
|
{ code: 'ko', name: '한국어', flag: 'kr' },
|
|
{ code: 'es', name: 'Español', flag: 'es' },
|
|
{ code: 'fr', name: 'Français', flag: 'fr' },
|
|
{ code: 'de', name: 'Deutsch', flag: 'de' },
|
|
{ code: 'ru', name: 'Русский', flag: 'ru' }
|
|
];
|
|
|
|
// Tutorial steps configuration
|
|
this.steps = [
|
|
{
|
|
target: '.controls .action-buttons [data-action="fetch"]',
|
|
title: () => translate('onboarding.steps.fetch.title', {}, 'Fetch Models Metadata'),
|
|
content: () => translate('onboarding.steps.fetch.content', {}, 'Click the <strong>Fetch</strong> button to download model metadata and preview images from Civitai.'),
|
|
position: 'bottom'
|
|
},
|
|
{
|
|
target: '.controls .action-buttons [data-action="download"]',
|
|
title: () => translate('onboarding.steps.download.title', {}, 'Download New Models'),
|
|
content: () => translate('onboarding.steps.download.content', {}, 'Use the <strong>Download</strong> button to download models directly from Civitai URLs.'),
|
|
position: 'bottom'
|
|
},
|
|
{
|
|
target: '.controls .action-buttons [data-action="bulk"]',
|
|
title: () => translate('onboarding.steps.bulk.title', {}, 'Bulk Operations'),
|
|
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span>. Select multiple models and perform batch operations. Use <span class="onboarding-shortcut">Ctrl+A</span> to select all visible models.'),
|
|
position: 'bottom'
|
|
},
|
|
{
|
|
target: '#searchOptionsToggle',
|
|
title: () => translate('onboarding.steps.searchOptions.title', {}, 'Search Options'),
|
|
content: () => translate('onboarding.steps.searchOptions.content', {}, 'Click this button to configure what fields to search in: filename, model name, tags, or creator name. Customize your search scope.'),
|
|
position: 'bottom'
|
|
},
|
|
{
|
|
target: '#filterButton',
|
|
title: () => translate('onboarding.steps.filter.title', {}, 'Filter Models'),
|
|
content: () => translate('onboarding.steps.filter.content', {}, 'Use filters to narrow down models by base model type (SD1.5, SDXL, Flux, etc.) or by specific tags.'),
|
|
position: 'bottom'
|
|
},
|
|
{
|
|
target: '#breadcrumbContainer',
|
|
title: () => translate('onboarding.steps.breadcrumb.title', {}, 'Breadcrumb Navigation'),
|
|
content: () => translate('onboarding.steps.breadcrumb.content', {}, 'The breadcrumb navigation shows your current path and allows quick navigation between folders. Click any folder name to jump directly there.'),
|
|
position: 'bottom'
|
|
},
|
|
{
|
|
target: '.card-grid',
|
|
title: () => translate('onboarding.steps.modelCards.title', {}, 'Model Cards'),
|
|
content: () => translate('onboarding.steps.modelCards.content', {}, '<strong>Single-click</strong> a model card to view detailed information and edit metadata. Look for the pencil icon when hovering over editable fields.'),
|
|
position: 'top',
|
|
customPosition: { top: '20%', left: '50%' }
|
|
},
|
|
{
|
|
target: '.card-grid',
|
|
title: () => translate('onboarding.steps.contextMenu.title', {}, 'Context Menu'),
|
|
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with additional actions.'),
|
|
position: 'top',
|
|
customPosition: { top: '20%', left: '50%' }
|
|
}
|
|
];
|
|
}
|
|
|
|
// Check if user should see onboarding
|
|
// 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;
|
|
}
|
|
|
|
// Start the onboarding process
|
|
async start() {
|
|
const shouldShow = await this.shouldShowOnboarding();
|
|
if (!shouldShow) {
|
|
return;
|
|
}
|
|
|
|
// If language has already been set, skip language selection
|
|
if (getStorageItem('onboarding_language_set')) {
|
|
this.startTutorial();
|
|
return;
|
|
}
|
|
|
|
// Show language selection first
|
|
await this.showLanguageSelection();
|
|
}
|
|
|
|
// Show language selection modal
|
|
showLanguageSelection() {
|
|
return new Promise((resolve) => {
|
|
// Initialize selected language from current settings
|
|
this.selectedLanguage = state.global.settings.language || 'en';
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'language-selection-modal';
|
|
modal.innerHTML = `
|
|
<div class="language-selection-content">
|
|
<h2>${translate('onboarding.languageSelection.title', {}, 'Welcome to LoRA Manager')}</h2>
|
|
<p>Choose Your Language / 选择语言 / 言語を選択</p>
|
|
<div class="language-grid">
|
|
${this.languages.map(lang => `
|
|
<div class="language-option" data-language="${lang.code}">
|
|
<span class="language-flag">
|
|
<span class="fi fi-${lang.flag}"></span>
|
|
</span>
|
|
<span class="language-name">${lang.name}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
<div class="language-actions">
|
|
<button class="onboarding-btn" id="skipLanguageBtn">${translate('onboarding.tutorial.skipTutorial', {}, 'Skip Tutorial')}</button>
|
|
<button class="onboarding-btn primary" id="continueLanguageBtn">${translate('onboarding.languageSelection.continue', {}, 'Continue')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
// Handle language selection
|
|
modal.querySelectorAll('.language-option').forEach(option => {
|
|
option.addEventListener('click', () => {
|
|
modal.querySelectorAll('.language-option').forEach(opt => opt.classList.remove('selected'));
|
|
option.classList.add('selected');
|
|
this.selectedLanguage = option.dataset.language;
|
|
});
|
|
});
|
|
|
|
// Handle continue button
|
|
document.getElementById('continueLanguageBtn').addEventListener('click', async () => {
|
|
const currentLanguage = state.global.settings.language || 'en';
|
|
|
|
// Only change language if it's different from current setting
|
|
if (this.selectedLanguage !== currentLanguage) {
|
|
await this.changeLanguage(this.selectedLanguage);
|
|
} else {
|
|
document.body.removeChild(modal);
|
|
this.startTutorial();
|
|
resolve();
|
|
}
|
|
});
|
|
|
|
// Handle skip button - skip entire tutorial
|
|
document.getElementById('skipLanguageBtn').addEventListener('click', async () => {
|
|
document.body.removeChild(modal);
|
|
await this.skip(); // Skip entire tutorial instead of just language selection
|
|
resolve();
|
|
});
|
|
|
|
// Select current language by default
|
|
const currentLanguageOption = modal.querySelector(`[data-language="${this.selectedLanguage}"]`);
|
|
if (currentLanguageOption) {
|
|
currentLanguageOption.classList.add('selected');
|
|
} else {
|
|
// Fallback to English if current language not found
|
|
modal.querySelector('[data-language="en"]').classList.add('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Change language using existing settings manager
|
|
async changeLanguage(languageCode) {
|
|
try {
|
|
// Update state
|
|
state.global.settings.language = languageCode;
|
|
|
|
// Save to backend
|
|
const response = await fetch('/api/lm/settings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
language: languageCode
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Mark onboarding as started before reload
|
|
setStorageItem('onboarding_language_set', true);
|
|
window.location.reload();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to change language:', error);
|
|
showToast('onboarding.languageSelection.changeFailed', { message: error.message }, 'error');
|
|
}
|
|
}
|
|
|
|
// Start the tutorial steps
|
|
async startTutorial() {
|
|
this.isActive = true;
|
|
this.currentStep = 0;
|
|
this.createOverlay();
|
|
await this.showStep(0);
|
|
}
|
|
|
|
// Create overlay elements
|
|
createOverlay() {
|
|
// Create overlay
|
|
this.overlay = document.createElement('div');
|
|
this.overlay.className = 'onboarding-overlay active';
|
|
document.body.appendChild(this.overlay);
|
|
|
|
// Create spotlight
|
|
this.spotlight = document.createElement('div');
|
|
this.spotlight.className = 'onboarding-spotlight';
|
|
document.body.appendChild(this.spotlight);
|
|
|
|
// Create popup
|
|
this.popup = document.createElement('div');
|
|
this.popup.className = 'onboarding-popup';
|
|
document.body.appendChild(this.popup);
|
|
}
|
|
|
|
// Show specific step
|
|
async showStep(stepIndex) {
|
|
if (stepIndex >= this.steps.length) {
|
|
await this.complete();
|
|
return;
|
|
}
|
|
|
|
const step = this.steps[stepIndex];
|
|
const target = document.querySelector(step.target);
|
|
|
|
if (!target && step.target !== 'body') {
|
|
// Skip this step if target not found
|
|
await this.showStep(stepIndex + 1);
|
|
return;
|
|
}
|
|
|
|
// Clear previous target highlighting
|
|
this.clearTargetHighlight();
|
|
|
|
// Position spotlight and create mask
|
|
if (target && step.target !== 'body') {
|
|
this.highlightTarget(target);
|
|
} else {
|
|
this.spotlight.style.display = 'none';
|
|
this.clearOverlayMask();
|
|
}
|
|
|
|
// Update popup content
|
|
this.popup.innerHTML = `
|
|
<h3>${typeof step.title === 'function' ? step.title() : step.title}</h3>
|
|
<p>${typeof step.content === 'function' ? step.content() : step.content}</p>
|
|
<div class="onboarding-controls">
|
|
<div class="onboarding-progress">
|
|
<span>${stepIndex + 1} / ${this.steps.length}</span>
|
|
</div>
|
|
<div class="onboarding-actions">
|
|
<button class="onboarding-btn" onclick="onboardingManager.skip()">${translate('onboarding.tutorial.skipTutorial', {}, 'Skip Tutorial')}</button>
|
|
${stepIndex > 0 ? `<button class="onboarding-btn" onclick="onboardingManager.previousStep()">${translate('onboarding.tutorial.back', {}, 'Back')}</button>` : ''}
|
|
<button class="onboarding-btn primary" onclick="onboardingManager.nextStep()">
|
|
${stepIndex === this.steps.length - 1 ? translate('onboarding.tutorial.finish', {}, 'Finish') : translate('onboarding.tutorial.next', {}, 'Next')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Position popup
|
|
this.positionPopup(step, target);
|
|
|
|
this.currentStep = stepIndex;
|
|
}
|
|
|
|
// Position popup relative to target
|
|
positionPopup(step, target) {
|
|
const popup = this.popup;
|
|
const windowWidth = window.innerWidth;
|
|
const windowHeight = window.innerHeight;
|
|
|
|
if (step.customPosition) {
|
|
popup.style.left = step.customPosition.left;
|
|
popup.style.top = step.customPosition.top;
|
|
popup.style.transform = 'translate(-50%, 0)';
|
|
return;
|
|
}
|
|
|
|
if (!target || step.target === 'body') {
|
|
popup.style.left = '50%';
|
|
popup.style.top = '50%';
|
|
popup.style.transform = 'translate(-50%, -50%)';
|
|
return;
|
|
}
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
const popupRect = popup.getBoundingClientRect();
|
|
|
|
let left, top;
|
|
|
|
switch (step.position) {
|
|
case 'bottom':
|
|
left = rect.left + (rect.width / 2) - (popupRect.width / 2);
|
|
top = rect.bottom + 20;
|
|
break;
|
|
case 'top':
|
|
left = rect.left + (rect.width / 2) - (popupRect.width / 2);
|
|
top = rect.top - popupRect.height - 20;
|
|
break;
|
|
case 'right':
|
|
left = rect.right + 20;
|
|
top = rect.top + (rect.height / 2) - (popupRect.height / 2);
|
|
break;
|
|
case 'left':
|
|
left = rect.left - popupRect.width - 20;
|
|
top = rect.top + (rect.height / 2) - (popupRect.height / 2);
|
|
break;
|
|
default:
|
|
left = rect.left + (rect.width / 2) - (popupRect.width / 2);
|
|
top = rect.bottom + 20;
|
|
}
|
|
|
|
// Ensure popup stays within viewport
|
|
left = Math.max(20, Math.min(left, windowWidth - popupRect.width - 20));
|
|
top = Math.max(20, Math.min(top, windowHeight - popupRect.height - 20));
|
|
|
|
popup.style.left = `${left}px`;
|
|
popup.style.top = `${top}px`;
|
|
popup.style.transform = 'none';
|
|
}
|
|
|
|
// Highlight target element with mask approach
|
|
highlightTarget(target) {
|
|
const rect = target.getBoundingClientRect();
|
|
const padding = 4; // Padding around the target element
|
|
const offset = 3; // Shift spotlight up and left by 3px
|
|
|
|
// Position spotlight
|
|
this.spotlight.style.left = `${rect.left - padding - offset}px`;
|
|
this.spotlight.style.top = `${rect.top - padding - offset}px`;
|
|
this.spotlight.style.width = `${rect.width + padding * 2}px`;
|
|
this.spotlight.style.height = `${rect.height + padding * 2}px`;
|
|
this.spotlight.style.display = 'block';
|
|
|
|
// Create mask for overlay to cut out the highlighted area
|
|
this.createOverlayMask(rect, padding, offset);
|
|
|
|
// Add highlight class to target and ensure it's interactive
|
|
target.classList.add('onboarding-target-highlight');
|
|
this.currentTarget = target;
|
|
|
|
// Add pulsing animation
|
|
this.spotlight.classList.add('onboarding-highlight');
|
|
}
|
|
|
|
// Create mask for overlay to cut out highlighted area
|
|
createOverlayMask(rect, padding, offset = 0) {
|
|
const x = rect.left - padding - offset;
|
|
const y = rect.top - padding - offset;
|
|
const width = rect.width + padding * 2;
|
|
const height = rect.height + padding * 2;
|
|
|
|
// Create SVG mask
|
|
const maskId = 'onboarding-mask';
|
|
let maskSvg = document.getElementById(maskId);
|
|
|
|
if (!maskSvg) {
|
|
maskSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
maskSvg.id = maskId;
|
|
maskSvg.style.position = 'absolute';
|
|
maskSvg.style.top = '0';
|
|
maskSvg.style.left = '0';
|
|
maskSvg.style.width = '100%';
|
|
maskSvg.style.height = '100%';
|
|
maskSvg.style.pointerEvents = 'none';
|
|
document.body.appendChild(maskSvg);
|
|
}
|
|
|
|
// Clear existing mask content
|
|
maskSvg.innerHTML = `
|
|
<defs>
|
|
<mask id="overlay-mask">
|
|
<rect width="100%" height="100%" fill="white"/>
|
|
<rect x="${x}" y="${y}" width="${width}" height="${height}"
|
|
rx="8" ry="8" fill="black"/>
|
|
</mask>
|
|
</defs>
|
|
`;
|
|
|
|
// Apply mask to overlay
|
|
this.overlay.style.mask = 'url(#overlay-mask)';
|
|
this.overlay.style.webkitMask = 'url(#overlay-mask)';
|
|
}
|
|
|
|
// Clear overlay mask
|
|
clearOverlayMask() {
|
|
if (this.overlay) {
|
|
this.overlay.style.mask = 'none';
|
|
this.overlay.style.webkitMask = 'none';
|
|
}
|
|
|
|
const maskSvg = document.getElementById('onboarding-mask');
|
|
if (maskSvg) {
|
|
maskSvg.remove();
|
|
}
|
|
}
|
|
|
|
// Clear target highlighting
|
|
clearTargetHighlight() {
|
|
if (this.currentTarget) {
|
|
this.currentTarget.classList.remove('onboarding-target-highlight');
|
|
this.currentTarget = null;
|
|
}
|
|
|
|
if (this.spotlight) {
|
|
this.spotlight.classList.remove('onboarding-highlight');
|
|
}
|
|
}
|
|
|
|
// Navigate to next step
|
|
async nextStep() {
|
|
await this.showStep(this.currentStep + 1);
|
|
}
|
|
|
|
// Navigate to previous step
|
|
async previousStep() {
|
|
if (this.currentStep > 0) {
|
|
await this.showStep(this.currentStep - 1);
|
|
}
|
|
}
|
|
|
|
// Skip the tutorial
|
|
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
|
|
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();
|
|
}
|
|
|
|
// Clean up overlay elements
|
|
cleanup() {
|
|
this.clearTargetHighlight();
|
|
this.clearOverlayMask();
|
|
|
|
if (this.overlay) {
|
|
document.body.removeChild(this.overlay);
|
|
this.overlay = null;
|
|
}
|
|
if (this.spotlight) {
|
|
document.body.removeChild(this.spotlight);
|
|
this.spotlight = null;
|
|
}
|
|
if (this.popup) {
|
|
document.body.removeChild(this.popup);
|
|
this.popup = null;
|
|
}
|
|
this.isActive = false;
|
|
}
|
|
|
|
// Reset onboarding status (for testing)
|
|
reset() {
|
|
localStorage.removeItem('lora_manager_onboarding_completed');
|
|
localStorage.removeItem('lora_manager_onboarding_skipped');
|
|
localStorage.removeItem('lora_manager_onboarding_language_set');
|
|
localStorage.setItem('lora_manager_version_info', '0.8.30-2546581');
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
export const onboardingManager = new OnboardingManager();
|
|
|
|
// Make it globally available for button handlers
|
|
window.onboardingManager = onboardingManager;
|