import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { state } from '../state/index.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: 'Fetch Models Metadata', content: 'Click the Fetch button to download model metadata and preview images from Civitai.', position: 'bottom' }, { target: '.controls .action-buttons [data-action="download"]', title: 'Download New Models', content: 'Use the Download button to download models directly from Civitai URLs.', position: 'bottom' }, { target: '.controls .action-buttons [data-action="bulk"]', title: 'Bulk Operations', content: 'Enter bulk mode by clicking this button or pressing B. Select multiple models and perform batch operations. Use Ctrl+A to select all visible models.', position: 'bottom' }, { target: '#searchOptionsToggle', title: 'Search Options', 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: 'Filter Models', 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: 'Breadcrumb Navigation', 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: 'Model Cards', content: 'Single-click 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: 'Context Menu', content: 'Right-click any model card for a context menu with additional actions.', position: 'top', customPosition: { top: '20%', left: '50%' } } ]; } // Check if user should see onboarding shouldShowOnboarding() { const completed = getStorageItem('onboarding_completed'); const skipped = getStorageItem('onboarding_skipped'); return !completed && !skipped; } // Start the onboarding process async start() { if (!this.shouldShowOnboarding()) { 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 = `

Welcome to LoRA Manager

Choose Your Language / 选择语言 / 言語を選択

${this.languages.map(lang => `
${lang.name}
`).join('')}
`; 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', () => { document.body.removeChild(modal); 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 localStorage setStorageItem('settings', state.global.settings); // Save to backend const response = await fetch('/api/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); } } // Start the tutorial steps startTutorial() { this.isActive = true; this.currentStep = 0; this.createOverlay(); 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); // Handle clicks outside popup this.overlay.addEventListener('click', (e) => { if (e.target === this.overlay) { this.skip(); } }); } // Show specific step showStep(stepIndex) { if (stepIndex >= this.steps.length) { 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 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 = `

${step.title}

${step.content}

${stepIndex + 1} / ${this.steps.length}
${stepIndex > 0 ? '' : ''}
`; // 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 = ` `; // Apply mask to overlay this.overlay.style.mask = 'url(#overlay-mask)'; this.overlay.style.webkitMask = 'url(#overlay-mask)'; } // Clear overlay mask clearOverlayMask() { 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 nextStep() { this.showStep(this.currentStep + 1); } // Navigate to previous step previousStep() { if (this.currentStep > 0) { this.showStep(this.currentStep - 1); } } // Skip the tutorial skip() { setStorageItem('onboarding_skipped', true); this.cleanup(); } // Complete the tutorial complete() { 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'); } } // Create singleton instance export const onboardingManager = new OnboardingManager(); // Make it globally available for button handlers window.onboardingManager = onboardingManager;