From 2bcf341f047390301bbeef1697f7498f4f847e1b Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 3 Sep 2025 15:42:36 +0800 Subject: [PATCH] feat(onboarding): implement onboarding tutorial with language selection and step guidance --- static/css/onboarding.css | 227 ++++++++++++++ static/js/core.js | 6 + static/js/managers/OnboardingManager.js | 393 ++++++++++++++++++++++++ static/js/managers/SettingsManager.js | 2 +- templates/base.html | 1 + 5 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 static/css/onboarding.css create mode 100644 static/js/managers/OnboardingManager.js diff --git a/static/css/onboarding.css b/static/css/onboarding.css new file mode 100644 index 00000000..6dc17fbc --- /dev/null +++ b/static/css/onboarding.css @@ -0,0 +1,227 @@ +/* Onboarding Tutorial Styles */ +.onboarding-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: var(--z-overlay); + display: none; +} + +.onboarding-overlay.active { + display: block; +} + +.onboarding-spotlight { + position: absolute; + background: transparent; + border: 3px solid var(--lora-accent); + border-radius: var(--border-radius-base); + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.8); + z-index: calc(var(--z-overlay) + 1); + pointer-events: none; + transition: all 0.3s ease; +} + +.onboarding-popup { + position: absolute; + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-base); + padding: var(--space-3); + min-width: 320px; + max-width: 400px; + z-index: calc(var(--z-overlay) + 2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(10px); +} + +.onboarding-popup h3 { + margin: 0 0 var(--space-2) 0; + color: var(--lora-accent); + font-size: 1.2em; + font-weight: 600; +} + +.onboarding-popup p { + margin: 0 0 var(--space-3) 0; + color: var(--text-color); + line-height: 1.5; +} + +.onboarding-controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); +} + +.onboarding-progress { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: 0.85em; + color: var(--text-muted); +} + +.onboarding-actions { + display: flex; + gap: var(--space-2); +} + +.onboarding-btn { + padding: var(--space-1) var(--space-2); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + background: var(--card-bg); + color: var(--text-color); + cursor: pointer; + font-size: 0.9em; + transition: all 0.2s ease; +} + +.onboarding-btn:hover { + background: var(--lora-accent); + color: var(--lora-text); + border-color: var(--lora-accent); +} + +.onboarding-btn.primary { + background: var(--lora-accent); + color: var(--lora-text); + border-color: var(--lora-accent); +} + +.onboarding-btn.primary:hover { + opacity: 0.9; +} + +/* Language Selection Modal */ +.language-selection-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: calc(var(--z-overlay) + 10); +} + +.language-selection-content { + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-base); + padding: var(--space-3); + min-width: 400px; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(10px); +} + +.language-selection-content h2 { + margin: 0 0 var(--space-2) 0; + color: var(--lora-accent); + font-size: 1.5em; +} + +.language-selection-content p { + margin: 0 0 var(--space-3) 0; + color: var(--text-color); + line-height: 1.5; +} + +.language-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.language-option { + padding: var(--space-2); + border: 2px solid var(--lora-border); + border-radius: var(--border-radius-sm); + background: var(--card-bg); + cursor: pointer; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); +} + +.language-option:hover { + border-color: var(--lora-accent); + background: var(--lora-surface); +} + +.language-option.selected { + border-color: var(--lora-accent); + background: var(--lora-accent); + color: var(--lora-text); +} + +.language-flag { + font-size: 1.5em; +} + +.language-name { + font-size: 0.9em; + font-weight: 500; +} + +.language-actions { + display: flex; + gap: var(--space-2); + justify-content: center; +} + +/* Shortcut Key Highlighting */ +.onboarding-shortcut { + display: inline-block; + background: var(--shortcut-bg); + border: 1px solid var(--shortcut-border); + border-radius: var(--border-radius-xs); + padding: 2px 6px; + font-size: 0.8em; + font-weight: 600; + color: var(--shortcut-text); + margin: 0 2px; +} + +/* Animation for highlighting elements */ +.onboarding-highlight { + animation: onboarding-pulse 2s infinite; +} + +@keyframes onboarding-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 var(--lora-accent); + } + 50% { + box-shadow: 0 0 0 8px transparent; + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .onboarding-popup { + min-width: 280px; + max-width: calc(100vw - 40px); + padding: var(--space-2); + } + + .language-grid { + grid-template-columns: repeat(2, 1fr); + } + + .language-selection-content { + min-width: calc(100vw - 40px); + max-width: 400px; + } +} diff --git a/static/js/core.js b/static/js/core.js index eba47200..36376d89 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -14,6 +14,7 @@ import { initTheme, initBackToTop } from './utils/uiHelpers.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { migrateStorageItems } from './utils/storageHelpers.js'; import { i18n } from './i18n/index.js'; +import { onboardingManager } from './managers/OnboardingManager.js'; // Core application class export class AppCore { @@ -65,6 +66,11 @@ export class AppCore { // Mark as initialized this.initialized = true; + // Start onboarding if needed (after everything is initialized) + setTimeout(() => { + onboardingManager.start(); + }, 1000); // Small delay to ensure all elements are rendered + // Return the core instance for chaining return this; } diff --git a/static/js/managers/OnboardingManager.js b/static/js/managers/OnboardingManager.js new file mode 100644 index 00000000..8f3eca5b --- /dev/null +++ b/static/js/managers/OnboardingManager.js @@ -0,0 +1,393 @@ +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'; + this.overlay = null; + this.spotlight = null; + this.popup = null; + + // Available languages with flag emojis + this.languages = [ + { code: 'en', name: 'English', flag: '🇺🇸' }, + { code: 'zh-cn', name: '简体中文', flag: '🇨🇳' }, + { code: 'zh-tw', name: '繁體中文', flag: '🇹🇼' }, + { code: 'ja', name: '日本語', flag: '🇯🇵' }, + { code: 'ko', name: '한국어', flag: '🇰🇷' }, + { code: 'es', name: 'Español', flag: '🇪🇸' }, + { code: 'fr', name: 'Français', flag: '🇫🇷' }, + { code: 'de', name: 'Deutsch', flag: '🇩🇪' }, + { code: 'ru', name: 'Русский', flag: '🇷🇺' } + ]; + + // 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. This enriches your local models with detailed information.', + 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. Simply paste a model URL and choose your download location.', + 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: '#searchInput', + title: 'Search Your Models', + content: 'Use the search bar to quickly find models by filename, model name, tags, or creator. Type your search terms here.', + 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. Great for organizing large collections.', + position: 'bottom' + }, + { + target: 'body', + title: 'Folder Navigation', + content: 'Move your mouse to the left edge of the window to reveal the folder sidebar. You can pin it for permanent access and navigate through your model directories.', + position: 'center', + customPosition: { top: '20%', left: '50%' } + }, + { + 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: '10%', left: '50%' } + }, + { + target: '.card-grid', + title: 'Context Menu & Quick Actions', + content: 'Right-click any model card for a context menu with additional actions. Click the airplane icon to send to ComfyUI workflow (hold Shift to replace existing content).', + position: 'top', + customPosition: { top: '10%', 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; + } + + // Show language selection first + await this.showLanguageSelection(); + } + + // Show language selection modal + showLanguageSelection() { + return new Promise((resolve) => { + const modal = document.createElement('div'); + modal.className = 'language-selection-modal'; + modal.innerHTML = ` +
+

Welcome to LoRA Manager

+

Choose your preferred language to get started, or continue with English.

+
+ ${this.languages.map(lang => ` +
+ ${lang.flag} + ${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 () => { + if (this.selectedLanguage !== 'en') { + // Save language and reload page + await this.changeLanguage(this.selectedLanguage); + } + document.body.removeChild(modal); + this.startTutorial(); + resolve(); + }); + + // Handle skip button + document.getElementById('skipLanguageBtn').addEventListener('click', () => { + document.body.removeChild(modal); + this.startTutorial(); + resolve(); + }); + + // Select English by default + 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; + } + + // Position spotlight + if (target && step.target !== 'body') { + const rect = target.getBoundingClientRect(); + this.spotlight.style.left = `${rect.left - 5}px`; + this.spotlight.style.top = `${rect.top - 5}px`; + this.spotlight.style.width = `${rect.width + 10}px`; + this.spotlight.style.height = `${rect.height + 10}px`; + this.spotlight.style.display = 'block'; + } else { + this.spotlight.style.display = 'none'; + } + + // 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'; + } + + // 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() { + 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; diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 033cfd7e..b31613e2 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -971,7 +971,7 @@ export class SettingsManager { // Save to localStorage setStorageItem('settings', state.global.settings); - // 保存到后端 + // Save to backend const response = await fetch('/api/settings', { method: 'POST', headers: { diff --git a/templates/base.html b/templates/base.html index bf8600d4..ece094a8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,6 +5,7 @@ {% block title %}{{ t('header.appTitle') }}{% endblock %} + {% block page_css %}{% endblock %}