feat(onboarding): implement onboarding tutorial with language selection and step guidance

This commit is contained in:
Will Miao
2025-09-03 15:42:36 +08:00
parent ca6f45b359
commit 2bcf341f04
5 changed files with 628 additions and 1 deletions

227
static/css/onboarding.css Normal file
View File

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

View File

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

View File

@@ -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 <strong>Fetch</strong> 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 <strong>Download</strong> 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 <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: '#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 <strong>left edge</strong> 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: '<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: '10%', left: '50%' }
},
{
target: '.card-grid',
title: 'Context Menu & Quick Actions',
content: '<strong>Right-click</strong> any model card for a context menu with additional actions. Click the <strong>airplane icon</strong> to send to ComfyUI workflow (hold <span class="onboarding-shortcut">Shift</span> 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 = `
<div class="language-selection-content">
<h2>Welcome to LoRA Manager</h2>
<p>Choose your preferred language to get started, or continue with English.</p>
<div class="language-grid">
${this.languages.map(lang => `
<div class="language-option" data-language="${lang.code}">
<span class="language-flag">${lang.flag}</span>
<span class="language-name">${lang.name}</span>
</div>
`).join('')}
</div>
<div class="language-actions">
<button class="onboarding-btn" id="skipLanguageBtn">Skip</button>
<button class="onboarding-btn primary" id="continueLanguageBtn">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 () => {
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 = `
<h3>${step.title}</h3>
<p>${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()">Skip Tutorial</button>
${stepIndex > 0 ? '<button class="onboarding-btn" onclick="onboardingManager.previousStep()">Back</button>' : ''}
<button class="onboarding-btn primary" onclick="onboardingManager.nextStep()">
${stepIndex === this.steps.length - 1 ? 'Finish' : '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';
}
// 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;

View File

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

View File

@@ -5,6 +5,7 @@
<title>{% block title %}{{ t('header.appTitle') }}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/loras_static/css/style.css">
<link rel="stylesheet" href="/loras_static/css/onboarding.css">
{% block page_css %}{% endblock %}
<link rel="stylesheet" href="/loras_static/vendor/font-awesome/css/all.min.css"
crossorigin="anonymous" referrerpolicy="no-referrer">