initial
This commit is contained in:
88
static/components/CourseList.js
Normal file
88
static/components/CourseList.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Course List Component
|
||||
export class CourseList {
|
||||
constructor(onSectionClick) {
|
||||
this.container = document.getElementById('courseList');
|
||||
this.onSectionClick = onSectionClick;
|
||||
this.courseData = null;
|
||||
}
|
||||
|
||||
async loadCourseContent() {
|
||||
if (this.container?.querySelector('.course-loaded')) {
|
||||
return; // Already loaded
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/course');
|
||||
const data = await response.json();
|
||||
this.courseData = data;
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'module-items';
|
||||
list.classList.add('course-loaded');
|
||||
|
||||
if (data.sections && data.sections.length > 0) {
|
||||
data.sections.forEach(section => {
|
||||
const sectionDiv = this.createSectionItem(section);
|
||||
list.appendChild(sectionDiv);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
this.container.appendChild(list);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.container) {
|
||||
this.container.innerHTML = `<div class="error-message">Error loading course: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createSectionItem(section) {
|
||||
const sectionDiv = document.createElement('div');
|
||||
sectionDiv.className = 'module-section';
|
||||
|
||||
const sectionTitle = document.createElement('h4');
|
||||
sectionTitle.textContent = section.title;
|
||||
sectionTitle.className = 'module-section-title';
|
||||
sectionDiv.appendChild(sectionTitle);
|
||||
|
||||
const itemsList = document.createElement('div');
|
||||
itemsList.className = 'module-items-list';
|
||||
|
||||
// Create clickable navigation item
|
||||
const navBtn = document.createElement('button');
|
||||
navBtn.className = 'module-item';
|
||||
navBtn.textContent = section.title;
|
||||
navBtn.addEventListener('click', () => {
|
||||
if (this.onSectionClick) {
|
||||
this.onSectionClick(section);
|
||||
}
|
||||
});
|
||||
itemsList.appendChild(navBtn);
|
||||
|
||||
// Add subsections if any
|
||||
if (section.subsections && section.subsections.length > 0) {
|
||||
section.subsections.forEach(subsection => {
|
||||
const subBtn = document.createElement('button');
|
||||
subBtn.className = 'module-item';
|
||||
subBtn.style.paddingLeft = '1.5rem';
|
||||
subBtn.textContent = subsection.title;
|
||||
subBtn.addEventListener('click', () => {
|
||||
if (this.onSectionClick) {
|
||||
this.onSectionClick(subsection);
|
||||
}
|
||||
});
|
||||
itemsList.appendChild(subBtn);
|
||||
});
|
||||
}
|
||||
|
||||
sectionDiv.appendChild(itemsList);
|
||||
return sectionDiv;
|
||||
}
|
||||
|
||||
getCourseData() {
|
||||
return this.courseData;
|
||||
}
|
||||
}
|
||||
|
||||
134
static/components/LanguageSelector.js
Normal file
134
static/components/LanguageSelector.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// Language Selector Component with Emojis
|
||||
export class LanguageSelector {
|
||||
constructor(onLanguageChange) {
|
||||
this.onLanguageChange = onLanguageChange;
|
||||
this.currentLang = '';
|
||||
this.languages = [
|
||||
{ code: '', name: 'English', emoji: '🇬🇧', native: 'English' },
|
||||
{ code: 'de', name: 'German', emoji: '🇩🇪', native: 'Deutsch' },
|
||||
{ code: 'fr', name: 'French', emoji: '🇫🇷', native: 'Français' },
|
||||
{ code: 'es', name: 'Spanish', emoji: '🇪🇸', native: 'Español' },
|
||||
{ code: 'it', name: 'Italian', emoji: '🇮🇹', native: 'Italiano' },
|
||||
{ code: 'pt', name: 'Portuguese', emoji: '🇵🇹', native: 'Português' },
|
||||
{ code: 'ru', name: 'Russian', emoji: '🇷🇺', native: 'Русский' }
|
||||
];
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Load saved language preference
|
||||
const savedLang = localStorage.getItem('preferredLanguage') || '';
|
||||
this.currentLang = savedLang;
|
||||
this.createSelector();
|
||||
}
|
||||
|
||||
createSelector() {
|
||||
// Create floating language selector
|
||||
const selector = document.createElement('div');
|
||||
selector.className = 'language-selector';
|
||||
selector.id = 'languageSelector';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'language-selector-button';
|
||||
button.setAttribute('aria-label', 'Select language');
|
||||
button.setAttribute('aria-haspopup', 'true');
|
||||
|
||||
const currentLang = this.languages.find(lang => lang.code === this.currentLang) || this.languages[0];
|
||||
button.innerHTML = `
|
||||
<span class="language-emoji">${currentLang.emoji}</span>
|
||||
<span class="language-name">${currentLang.native}</span>
|
||||
<span class="language-arrow">▼</span>
|
||||
`;
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'language-dropdown';
|
||||
dropdown.id = 'languageDropdown';
|
||||
|
||||
this.languages.forEach(lang => {
|
||||
const option = document.createElement('button');
|
||||
option.className = `language-option ${lang.code === this.currentLang ? 'active' : ''}`;
|
||||
option.dataset.code = lang.code;
|
||||
option.innerHTML = `
|
||||
<span class="language-emoji">${lang.emoji}</span>
|
||||
<span class="language-name">${lang.native}</span>
|
||||
<span class="language-name-en">${lang.name}</span>
|
||||
`;
|
||||
|
||||
option.addEventListener('click', () => {
|
||||
this.selectLanguage(lang.code);
|
||||
dropdown.classList.remove('active');
|
||||
});
|
||||
|
||||
dropdown.appendChild(option);
|
||||
});
|
||||
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isActive = dropdown.classList.contains('active');
|
||||
dropdown.classList.toggle('active');
|
||||
// Update arrow rotation
|
||||
const arrow = button.querySelector('.language-arrow');
|
||||
if (arrow) {
|
||||
arrow.style.transform = isActive ? 'rotate(0deg)' : 'rotate(180deg)';
|
||||
}
|
||||
});
|
||||
|
||||
selector.appendChild(button);
|
||||
selector.appendChild(dropdown);
|
||||
|
||||
// Store reference for arrow rotation
|
||||
this.button = button;
|
||||
this.dropdown = dropdown;
|
||||
|
||||
// Insert into content area (top right)
|
||||
const content = document.getElementById('content');
|
||||
if (content) {
|
||||
content.insertBefore(selector, content.firstChild);
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!selector.contains(e.target)) {
|
||||
dropdown.classList.remove('active');
|
||||
const arrow = button.querySelector('.language-arrow');
|
||||
if (arrow) {
|
||||
arrow.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectLanguage(code) {
|
||||
this.currentLang = code;
|
||||
localStorage.setItem('preferredLanguage', code);
|
||||
|
||||
// Update button display
|
||||
const currentLang = this.languages.find(lang => lang.code === code) || this.languages[0];
|
||||
const button = document.querySelector('.language-selector-button');
|
||||
if (button) {
|
||||
button.innerHTML = `
|
||||
<span class="language-emoji">${currentLang.emoji}</span>
|
||||
<span class="language-name">${currentLang.native}</span>
|
||||
<span class="language-arrow">▼</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Update active option
|
||||
document.querySelectorAll('.language-option').forEach(option => {
|
||||
if (option.dataset.code === code) {
|
||||
option.classList.add('active');
|
||||
} else {
|
||||
option.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
if (this.onLanguageChange) {
|
||||
this.onLanguageChange(code);
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentLanguage() {
|
||||
return this.currentLang;
|
||||
}
|
||||
}
|
||||
|
||||
158
static/components/LoadingProgress.js
Normal file
158
static/components/LoadingProgress.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// Loading Progress Component with Qt-style progress bar
|
||||
export class LoadingProgress {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.progressBar = null;
|
||||
this.progressFill = null;
|
||||
this.animationFrame = null;
|
||||
this.progress = 0;
|
||||
this.targetProgress = 0;
|
||||
this.isAnimating = false;
|
||||
}
|
||||
|
||||
show(message = 'Loading...', showProgress = true) {
|
||||
if (!this.container) return;
|
||||
|
||||
const loadingHTML = `
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
</div>
|
||||
<div class="loading-text">${message}</div>
|
||||
${showProgress ? `
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
<div class="progress-shine"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="progressText">0%</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = loadingHTML;
|
||||
|
||||
if (showProgress) {
|
||||
this.progressBar = this.container.querySelector('.progress-bar');
|
||||
this.progressFill = document.getElementById('progressFill');
|
||||
this.progressText = document.getElementById('progressText');
|
||||
this.startProgressAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
startProgressAnimation() {
|
||||
this.progress = 0;
|
||||
this.targetProgress = 0;
|
||||
this.isAnimating = true;
|
||||
|
||||
// More stable progress simulation with smoother steps
|
||||
const steps = [
|
||||
{ progress: 8, delay: 150 },
|
||||
{ progress: 20, delay: 250 },
|
||||
{ progress: 35, delay: 400 },
|
||||
{ progress: 50, delay: 350 },
|
||||
{ progress: 65, delay: 500 },
|
||||
{ progress: 78, delay: 450 },
|
||||
{ progress: 88, delay: 600 },
|
||||
{ progress: 95, delay: 700 },
|
||||
];
|
||||
|
||||
let stepIndex = 0;
|
||||
let isCancelled = false;
|
||||
this.cancelProgress = () => { isCancelled = true; };
|
||||
|
||||
const updateProgress = () => {
|
||||
if (isCancelled || !this.isAnimating) return;
|
||||
|
||||
if (stepIndex < steps.length) {
|
||||
const step = steps[stepIndex];
|
||||
this.targetProgress = step.progress;
|
||||
stepIndex++;
|
||||
setTimeout(updateProgress, step.delay);
|
||||
} else {
|
||||
// Keep at 95% until actual loading completes
|
||||
this.targetProgress = 95;
|
||||
}
|
||||
};
|
||||
|
||||
updateProgress();
|
||||
this.animateProgress();
|
||||
}
|
||||
|
||||
animateProgress() {
|
||||
if (!this.isAnimating || !this.progressFill) return;
|
||||
|
||||
// Smooth interpolation
|
||||
const diff = this.targetProgress - this.progress;
|
||||
if (Math.abs(diff) > 0.1) {
|
||||
this.progress += diff * 0.15; // Smooth easing
|
||||
} else {
|
||||
this.progress = this.targetProgress;
|
||||
}
|
||||
|
||||
if (this.progressFill) {
|
||||
this.progressFill.style.width = `${this.progress}%`;
|
||||
}
|
||||
|
||||
if (this.progressText) {
|
||||
this.progressText.textContent = `${Math.round(this.progress)}%`;
|
||||
}
|
||||
|
||||
// Add class when progress starts
|
||||
if (this.progressBar) {
|
||||
if (this.progress > 0) {
|
||||
this.progressBar.classList.add('has-progress');
|
||||
} else {
|
||||
this.progressBar.classList.remove('has-progress');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isAnimating) {
|
||||
this.animationFrame = requestAnimationFrame(() => this.animateProgress());
|
||||
}
|
||||
}
|
||||
|
||||
setProgress(value) {
|
||||
this.targetProgress = Math.min(100, Math.max(0, value));
|
||||
if (!this.isAnimating && this.progressFill) {
|
||||
this.startProgressAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
complete() {
|
||||
this.targetProgress = 100;
|
||||
// Animate to 100% and then hide
|
||||
const checkComplete = () => {
|
||||
if (this.progress >= 99.9) {
|
||||
setTimeout(() => {
|
||||
this.hide();
|
||||
}, 200);
|
||||
} else {
|
||||
setTimeout(checkComplete, 50);
|
||||
}
|
||||
};
|
||||
checkComplete();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.isAnimating = false;
|
||||
if (this.cancelProgress) {
|
||||
this.cancelProgress();
|
||||
}
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
}
|
||||
// Clear progress elements
|
||||
this.progressBar = null;
|
||||
this.progressFill = null;
|
||||
this.progressText = null;
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
79
static/components/ModuleList.js
Normal file
79
static/components/ModuleList.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// Module List Component
|
||||
export class ModuleList {
|
||||
constructor(onModuleClick) {
|
||||
this.container = document.getElementById('moduleList');
|
||||
this.onModuleClick = onModuleClick;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadModuleList();
|
||||
}
|
||||
|
||||
async loadModuleList() {
|
||||
try {
|
||||
const response = await fetch('/modules');
|
||||
const data = await response.json();
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'module-items';
|
||||
|
||||
// Add builtins section
|
||||
if (data.builtins && data.builtins.length > 0) {
|
||||
const builtinsSection = this.createSection('Builtins', data.builtins.slice(0, 20));
|
||||
list.appendChild(builtinsSection);
|
||||
}
|
||||
|
||||
// Add modules section
|
||||
if (data.modules && data.modules.length > 0) {
|
||||
const modulesSection = this.createSection('Standard Library', data.modules);
|
||||
list.appendChild(modulesSection);
|
||||
}
|
||||
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
this.container.appendChild(list);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.container) {
|
||||
this.container.innerHTML = `<div class="error-message">Error loading modules: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createSection(title, items) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'module-section';
|
||||
|
||||
const sectionTitle = document.createElement('h4');
|
||||
sectionTitle.textContent = title;
|
||||
sectionTitle.className = 'module-section-title';
|
||||
section.appendChild(sectionTitle);
|
||||
|
||||
const itemsList = document.createElement('div');
|
||||
itemsList.className = 'module-items-list';
|
||||
|
||||
items.forEach(item => {
|
||||
const fullName = item.full_name || (title === 'Builtins' ? `builtins.${item.name}` : item.name);
|
||||
const btn = this.createModuleButton(fullName, item.name);
|
||||
itemsList.appendChild(btn);
|
||||
});
|
||||
|
||||
section.appendChild(itemsList);
|
||||
return section;
|
||||
}
|
||||
|
||||
createModuleButton(fullName, displayName) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'module-item';
|
||||
btn.textContent = displayName;
|
||||
btn.title = fullName;
|
||||
btn.addEventListener('click', () => {
|
||||
if (this.onModuleClick) {
|
||||
this.onModuleClick(fullName);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
|
||||
233
static/components/Results.js
Normal file
233
static/components/Results.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// Results Component
|
||||
import { LoadingProgress } from './LoadingProgress.js';
|
||||
|
||||
export class Results {
|
||||
constructor() {
|
||||
this.container = document.getElementById('results');
|
||||
this.loadingProgress = new LoadingProgress(this.container);
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Show welcome message initially
|
||||
this.showWelcome();
|
||||
}
|
||||
|
||||
showWelcome() {
|
||||
if (this.container) {
|
||||
this.container.innerHTML = `
|
||||
<div class="welcome-message">
|
||||
<h1>Python Documentation</h1>
|
||||
<p>Search for any Python object in the sidebar to view its documentation.</p>
|
||||
<p>Select a language to automatically translate the documentation.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
showLoading(message = 'Loading documentation...', showProgress = true) {
|
||||
if (this.container) {
|
||||
this.loadingProgress.show(message, showProgress);
|
||||
}
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
if (this.loadingProgress) {
|
||||
this.loadingProgress.complete();
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (this.container) {
|
||||
this.container.innerHTML = `<div class="error-message">${message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
displayDocumentation(data) {
|
||||
if (!this.container) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
|
||||
// Header with title
|
||||
const header = document.createElement('div');
|
||||
header.className = 'doc-header';
|
||||
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'doc-title';
|
||||
title.textContent = data.object_name;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'doc-meta';
|
||||
meta.innerHTML = `
|
||||
<span class="type-badge">${data.object_type || 'unknown'}</span>
|
||||
${data.cached ? '<span class="doc-badge">Cached</span>' : ''}
|
||||
`;
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(meta);
|
||||
wrapper.appendChild(header);
|
||||
|
||||
// Signature
|
||||
if (data.signature) {
|
||||
const signature = document.createElement('div');
|
||||
signature.className = 'doc-signature';
|
||||
signature.textContent = data.signature;
|
||||
wrapper.appendChild(signature);
|
||||
}
|
||||
|
||||
// Main documentation content
|
||||
const docText = data.translated || data.original;
|
||||
|
||||
if (docText) {
|
||||
const docSection = document.createElement('section');
|
||||
docSection.className = 'doc-section';
|
||||
|
||||
const docTextEl = document.createElement('div');
|
||||
docTextEl.className = 'doc-text';
|
||||
docTextEl.textContent = docText;
|
||||
|
||||
docSection.appendChild(docTextEl);
|
||||
wrapper.appendChild(docSection);
|
||||
}
|
||||
|
||||
// Show original if translation exists (collapsible)
|
||||
if (data.translated && data.original) {
|
||||
const originalSection = document.createElement('details');
|
||||
originalSection.className = 'doc-section';
|
||||
originalSection.innerHTML = `
|
||||
<summary style="cursor: pointer; font-weight: 500; margin-bottom: 0.5rem; color: var(--text-secondary);">
|
||||
Original Documentation (English)
|
||||
</summary>
|
||||
<div class="doc-text" style="margin-top: 0.5rem;">${data.original}</div>
|
||||
`;
|
||||
wrapper.appendChild(originalSection);
|
||||
}
|
||||
|
||||
if (!data.original && !data.translated) {
|
||||
const noDoc = document.createElement('div');
|
||||
noDoc.className = 'doc-text';
|
||||
noDoc.textContent = 'No documentation available for this object.';
|
||||
wrapper.appendChild(noDoc);
|
||||
}
|
||||
|
||||
this.container.innerHTML = '';
|
||||
this.container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
displayCourse(courseData) {
|
||||
if (!this.container) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'course-content';
|
||||
|
||||
const title = document.createElement('h1');
|
||||
title.textContent = courseData.title || 'Python Course';
|
||||
wrapper.appendChild(title);
|
||||
|
||||
if (courseData.sections && courseData.sections.length > 0) {
|
||||
courseData.sections.forEach(section => {
|
||||
const sectionDiv = this.createCourseSection(section);
|
||||
wrapper.appendChild(sectionDiv);
|
||||
});
|
||||
}
|
||||
|
||||
this.container.innerHTML = '';
|
||||
this.container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
createCourseSection(section) {
|
||||
const sectionDiv = document.createElement('section');
|
||||
sectionDiv.className = 'course-section';
|
||||
sectionDiv.id = `section-${section.id || section.title.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
|
||||
// Parse and render markdown
|
||||
if (section.markdown) {
|
||||
// Configure marked options
|
||||
if (typeof marked !== 'undefined') {
|
||||
marked.setOptions({
|
||||
highlight: function(code, lang) {
|
||||
if (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
} catch (err) {
|
||||
console.error('Highlight error:', err);
|
||||
}
|
||||
}
|
||||
if (typeof hljs !== 'undefined') {
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
return code;
|
||||
},
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
|
||||
// Convert markdown to HTML
|
||||
const htmlContent = marked.parse(section.markdown);
|
||||
|
||||
// Create a container for the markdown content
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'markdown-content';
|
||||
contentDiv.innerHTML = htmlContent;
|
||||
|
||||
// Add IDs to headings for navigation
|
||||
contentDiv.querySelectorAll('h1, h2, h3, h4').forEach((heading) => {
|
||||
const text = heading.textContent.trim();
|
||||
const id = text.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
heading.id = id;
|
||||
});
|
||||
|
||||
// Highlight code blocks
|
||||
if (typeof hljs !== 'undefined') {
|
||||
contentDiv.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}
|
||||
|
||||
sectionDiv.appendChild(contentDiv);
|
||||
}
|
||||
} else if (section.content && section.content.length > 0) {
|
||||
// Fallback to old format
|
||||
section.content.forEach(item => {
|
||||
if (item.startsWith('```')) {
|
||||
const codeDiv = document.createElement('pre');
|
||||
codeDiv.className = 'doc-signature';
|
||||
codeDiv.textContent = item.replace(/```python\n?/g, '').replace(/```/g, '').trim();
|
||||
sectionDiv.appendChild(codeDiv);
|
||||
} else {
|
||||
const itemDiv = document.createElement('p');
|
||||
itemDiv.className = 'doc-text';
|
||||
itemDiv.textContent = item;
|
||||
sectionDiv.appendChild(itemDiv);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sectionDiv;
|
||||
}
|
||||
|
||||
scrollToSection(section) {
|
||||
const sectionId = section.id || section.title.toLowerCase().replace(/\s+/g, '-');
|
||||
const sectionElement = document.getElementById(`section-${sectionId}`);
|
||||
if (sectionElement) {
|
||||
sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find by heading ID
|
||||
const headingId = section.title.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
const headingElement = document.getElementById(headingId);
|
||||
if (headingElement) {
|
||||
headingElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
281
static/components/SearchBar.js
Normal file
281
static/components/SearchBar.js
Normal file
@@ -0,0 +1,281 @@
|
||||
// Search Bar Component with Autocomplete
|
||||
export class SearchBar {
|
||||
constructor(onSearch, onSelect) {
|
||||
this.onSearch = onSearch;
|
||||
this.onSelect = onSelect;
|
||||
this.searchInput = null;
|
||||
this.suggestionsContainer = null;
|
||||
this.allItems = [];
|
||||
this.filteredItems = [];
|
||||
this.selectedIndex = -1;
|
||||
this.debounceTimer = null;
|
||||
this.isOpen = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadAllItems();
|
||||
this.createSearchBar();
|
||||
}
|
||||
|
||||
async loadAllItems() {
|
||||
try {
|
||||
const response = await fetch('/modules');
|
||||
const data = await response.json();
|
||||
|
||||
this.allItems = [];
|
||||
|
||||
// Add builtins
|
||||
if (data.builtins && data.builtins.length > 0) {
|
||||
data.builtins.forEach(item => {
|
||||
this.allItems.push({
|
||||
name: item.name,
|
||||
fullName: item.full_name || `builtins.${item.name}`,
|
||||
type: 'builtin',
|
||||
display: item.name
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add modules
|
||||
if (data.modules && data.modules.length > 0) {
|
||||
data.modules.forEach(item => {
|
||||
this.allItems.push({
|
||||
name: item.name,
|
||||
fullName: item.name,
|
||||
type: 'module',
|
||||
display: item.name
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading items:', error);
|
||||
this.allItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
createSearchBar() {
|
||||
const searchForm = document.querySelector('.search-form');
|
||||
if (!searchForm) return;
|
||||
|
||||
// Create search input
|
||||
const searchWrapper = document.createElement('div');
|
||||
searchWrapper.className = 'search-bar-wrapper';
|
||||
|
||||
const searchIcon = document.createElement('span');
|
||||
searchIcon.className = 'search-icon';
|
||||
searchIcon.innerHTML = '🔍';
|
||||
searchIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this.searchInput = document.createElement('input');
|
||||
this.searchInput.type = 'text';
|
||||
this.searchInput.className = 'search-bar-input';
|
||||
this.searchInput.placeholder = 'Search Python objects, modules, builtins...';
|
||||
this.searchInput.autocomplete = 'off';
|
||||
this.searchInput.setAttribute('aria-label', 'Search Python documentation');
|
||||
|
||||
// Create suggestions dropdown
|
||||
this.suggestionsContainer = document.createElement('div');
|
||||
this.suggestionsContainer.className = 'search-suggestions';
|
||||
this.suggestionsContainer.id = 'searchSuggestions';
|
||||
|
||||
searchWrapper.appendChild(searchIcon);
|
||||
searchWrapper.appendChild(this.searchInput);
|
||||
searchWrapper.appendChild(this.suggestionsContainer);
|
||||
|
||||
// Replace the old input group
|
||||
const oldInputGroup = searchForm.querySelector('.input-group');
|
||||
if (oldInputGroup) {
|
||||
oldInputGroup.replaceWith(searchWrapper);
|
||||
} else {
|
||||
searchForm.insertBefore(searchWrapper, searchForm.firstChild);
|
||||
}
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Debounced search on input
|
||||
this.searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(this.debounceTimer);
|
||||
const query = e.target.value.trim();
|
||||
|
||||
if (query.length === 0) {
|
||||
this.hideSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.filterItems(query);
|
||||
this.showSuggestions();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Handle keyboard navigation
|
||||
this.searchInput.addEventListener('keydown', (e) => {
|
||||
if (!this.isOpen || this.filteredItems.length === 0) {
|
||||
if (e.key === 'Enter' && this.searchInput.value.trim()) {
|
||||
this.performSearch(this.searchInput.value.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredItems.length - 1);
|
||||
this.updateSelection();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
||||
this.updateSelection();
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < this.filteredItems.length) {
|
||||
this.selectItem(this.filteredItems[this.selectedIndex]);
|
||||
} else if (this.searchInput.value.trim()) {
|
||||
this.performSearch(this.searchInput.value.trim());
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
this.hideSuggestions();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.searchInput.contains(e.target) &&
|
||||
!this.suggestionsContainer.contains(e.target)) {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-search on blur if there's a value and user typed something
|
||||
let hasTyped = false;
|
||||
this.searchInput.addEventListener('input', () => {
|
||||
hasTyped = true;
|
||||
});
|
||||
|
||||
this.searchInput.addEventListener('blur', () => {
|
||||
// Delay to allow click events on suggestions
|
||||
setTimeout(() => {
|
||||
const query = this.searchInput.value.trim();
|
||||
if (query && hasTyped && !this.isOpen) {
|
||||
this.performSearch(query);
|
||||
}
|
||||
hasTyped = false;
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
filterItems(query) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
this.filteredItems = this.allItems
|
||||
.filter(item => {
|
||||
const nameMatch = item.name.toLowerCase().includes(lowerQuery);
|
||||
const fullNameMatch = item.fullName.toLowerCase().includes(lowerQuery);
|
||||
return nameMatch || fullNameMatch;
|
||||
})
|
||||
.slice(0, 10); // Limit to 10 suggestions
|
||||
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
showSuggestions() {
|
||||
if (this.filteredItems.length === 0) {
|
||||
this.hideSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isOpen = true;
|
||||
this.suggestionsContainer.innerHTML = '';
|
||||
this.suggestionsContainer.classList.add('active');
|
||||
|
||||
this.filteredItems.forEach((item, index) => {
|
||||
const suggestion = document.createElement('div');
|
||||
suggestion.className = 'search-suggestion';
|
||||
suggestion.dataset.index = index;
|
||||
|
||||
const icon = item.type === 'builtin' ? '⚡' : '📦';
|
||||
suggestion.innerHTML = `
|
||||
<span class="suggestion-icon">${icon}</span>
|
||||
<span class="suggestion-name">${this.highlightMatch(item.name, this.searchInput.value)}</span>
|
||||
<span class="suggestion-type">${item.type}</span>
|
||||
`;
|
||||
|
||||
suggestion.addEventListener('click', () => {
|
||||
this.selectItem(item);
|
||||
});
|
||||
|
||||
suggestion.addEventListener('mouseenter', () => {
|
||||
this.selectedIndex = index;
|
||||
this.updateSelection();
|
||||
});
|
||||
|
||||
this.suggestionsContainer.appendChild(suggestion);
|
||||
});
|
||||
}
|
||||
|
||||
highlightMatch(text, query) {
|
||||
if (!query) return text;
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const index = lowerText.indexOf(lowerQuery);
|
||||
|
||||
if (index === -1) return text;
|
||||
|
||||
const before = text.substring(0, index);
|
||||
const match = text.substring(index, index + query.length);
|
||||
const after = text.substring(index + query.length);
|
||||
|
||||
return `${before}<mark>${match}</mark>${after}`;
|
||||
}
|
||||
|
||||
updateSelection() {
|
||||
const suggestions = this.suggestionsContainer.querySelectorAll('.search-suggestion');
|
||||
suggestions.forEach((suggestion, index) => {
|
||||
if (index === this.selectedIndex) {
|
||||
suggestion.classList.add('selected');
|
||||
suggestion.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
} else {
|
||||
suggestion.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectItem(item) {
|
||||
this.searchInput.value = item.fullName;
|
||||
this.hideSuggestions();
|
||||
|
||||
if (this.onSelect) {
|
||||
this.onSelect(item.fullName);
|
||||
}
|
||||
|
||||
this.performSearch(item.fullName);
|
||||
}
|
||||
|
||||
performSearch(query) {
|
||||
if (!query || query.trim().length === 0) return;
|
||||
|
||||
if (this.onSearch) {
|
||||
this.onSearch(query.trim());
|
||||
}
|
||||
}
|
||||
|
||||
hideSuggestions() {
|
||||
this.isOpen = false;
|
||||
this.selectedIndex = -1;
|
||||
this.suggestionsContainer.classList.remove('active');
|
||||
this.suggestionsContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
setValue(value) {
|
||||
if (this.searchInput) {
|
||||
this.searchInput.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
static/components/SearchForm.js
Normal file
8
static/components/SearchForm.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Search Form Component (simplified - now just a container)
|
||||
export class SearchForm {
|
||||
constructor() {
|
||||
// This component is now just a container
|
||||
// Actual search is handled by SearchBar component
|
||||
}
|
||||
}
|
||||
|
||||
123
static/components/Sidebar.js
Normal file
123
static/components/Sidebar.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// Sidebar Component
|
||||
export class Sidebar {
|
||||
constructor() {
|
||||
this.sidebar = document.getElementById('sidebar');
|
||||
this.resizeHandle = document.getElementById('resizeHandle');
|
||||
this.menuToggle = null;
|
||||
this.isResizing = false;
|
||||
this.startX = 0;
|
||||
this.startWidth = 0;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createMenuToggle();
|
||||
this.setupResize();
|
||||
this.setupMobileBehavior();
|
||||
}
|
||||
|
||||
createMenuToggle() {
|
||||
// Create mobile menu toggle button
|
||||
this.menuToggle = document.createElement('button');
|
||||
this.menuToggle.className = 'mobile-menu-toggle';
|
||||
this.menuToggle.innerHTML = '☰';
|
||||
this.menuToggle.setAttribute('aria-label', 'Toggle menu');
|
||||
this.menuToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleMobile();
|
||||
});
|
||||
|
||||
// Create overlay for mobile
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'sidebar-overlay';
|
||||
this.overlay.addEventListener('click', () => this.closeMobile());
|
||||
|
||||
// Insert at the beginning of body
|
||||
document.body.insertBefore(this.menuToggle, document.body.firstChild);
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
setupResize() {
|
||||
if (!this.resizeHandle || !this.sidebar) return;
|
||||
|
||||
this.resizeHandle.addEventListener('mousedown', (e) => {
|
||||
if (window.innerWidth <= 768) return; // Disable resize on mobile
|
||||
|
||||
this.isResizing = true;
|
||||
this.startX = e.clientX;
|
||||
this.startWidth = parseInt(window.getComputedStyle(this.sidebar).width, 10);
|
||||
document.addEventListener('mousemove', this.handleResize.bind(this));
|
||||
document.addEventListener('mouseup', this.stopResize.bind(this));
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
handleResize(e) {
|
||||
if (!this.isResizing) return;
|
||||
const width = this.startWidth + e.clientX - this.startX;
|
||||
const minWidth = 200;
|
||||
const maxWidth = 600;
|
||||
if (width >= minWidth && width <= maxWidth) {
|
||||
this.sidebar.style.width = `${width}px`;
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${width}px`);
|
||||
}
|
||||
}
|
||||
|
||||
stopResize() {
|
||||
this.isResizing = false;
|
||||
document.removeEventListener('mousemove', this.handleResize);
|
||||
document.removeEventListener('mouseup', this.stopResize);
|
||||
}
|
||||
|
||||
setupMobileBehavior() {
|
||||
// Close sidebar on window resize if switching to desktop
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth > 768) {
|
||||
this.closeMobile();
|
||||
}
|
||||
});
|
||||
|
||||
// Close sidebar when clicking on module items or search button on mobile
|
||||
if (this.sidebar) {
|
||||
this.sidebar.addEventListener('click', (e) => {
|
||||
if (window.innerWidth <= 768) {
|
||||
// Close if clicking on interactive elements (but not the toggle itself)
|
||||
if (e.target.closest('.module-item') ||
|
||||
e.target.closest('.search-btn') ||
|
||||
e.target.closest('.nav-tab')) {
|
||||
// Small delay to allow the click to register
|
||||
setTimeout(() => this.closeMobile(), 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleMobile() {
|
||||
const isOpen = this.sidebar?.classList.contains('open');
|
||||
if (isOpen) {
|
||||
this.closeMobile();
|
||||
} else {
|
||||
this.openMobile();
|
||||
}
|
||||
}
|
||||
|
||||
openMobile() {
|
||||
this.sidebar?.classList.add('open');
|
||||
if (this.overlay) {
|
||||
this.overlay.classList.add('active');
|
||||
}
|
||||
// Prevent body scroll when sidebar is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
closeMobile() {
|
||||
this.sidebar?.classList.remove('open');
|
||||
if (this.overlay) {
|
||||
this.overlay.classList.remove('active');
|
||||
}
|
||||
// Restore body scroll
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
54
static/components/Tabs.js
Normal file
54
static/components/Tabs.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Tabs Component
|
||||
export class Tabs {
|
||||
constructor(onTabChange) {
|
||||
this.navTabs = document.querySelectorAll('.nav-tab');
|
||||
this.docsTab = document.getElementById('docsTab');
|
||||
this.courseTab = document.getElementById('courseTab');
|
||||
this.onTabChange = onTabChange;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.navTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const tabName = tab.dataset.tab;
|
||||
this.switchTab(tabName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
switchTab(tabName) {
|
||||
// Update active tab
|
||||
this.navTabs.forEach(t => t.classList.remove('active'));
|
||||
const activeTab = Array.from(this.navTabs).find(t => t.dataset.tab === tabName);
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('active');
|
||||
}
|
||||
|
||||
// Show/hide tab content
|
||||
if (tabName === 'docs') {
|
||||
if (this.docsTab) {
|
||||
this.docsTab.style.display = 'flex';
|
||||
this.docsTab.classList.add('active');
|
||||
}
|
||||
if (this.courseTab) {
|
||||
this.courseTab.style.display = 'none';
|
||||
this.courseTab.classList.remove('active');
|
||||
}
|
||||
} else {
|
||||
if (this.docsTab) {
|
||||
this.docsTab.style.display = 'none';
|
||||
this.docsTab.classList.remove('active');
|
||||
}
|
||||
if (this.courseTab) {
|
||||
this.courseTab.style.display = 'flex';
|
||||
this.courseTab.classList.add('active');
|
||||
}
|
||||
|
||||
if (this.onTabChange) {
|
||||
this.onTabChange(tabName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
static/components/ThemeToggle.js
Normal file
26
static/components/ThemeToggle.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Theme Toggle Component
|
||||
export class ThemeToggle {
|
||||
constructor() {
|
||||
this.html = document.documentElement;
|
||||
this.toggle = document.getElementById('themeToggle');
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Load saved theme
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
this.html.setAttribute('data-theme', savedTheme);
|
||||
|
||||
if (this.toggle) {
|
||||
this.toggle.addEventListener('click', () => this.toggleTheme());
|
||||
}
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const currentTheme = this.html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
this.html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user