Старт

This commit is contained in:
2026-03-13 14:39:43 +08:00
commit a2cc480644
88 changed files with 18526 additions and 0 deletions

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM nginx:alpine
# Remove default nginx website
RUN rm -rf /usr/share/nginx/html/*
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy static files
COPY ./public /usr/share/nginx/html
COPY ./src /usr/share/nginx/html/src
COPY ./css /usr/share/nginx/html/css
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

40
frontend/css/auth.css Normal file
View File

@@ -0,0 +1,40 @@
/* Auth-specific styles */
.auth-required {
text-align: center;
padding: 3rem;
background-color: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.auth-required h2 {
color: #4b5563;
margin-bottom: 1rem;
}
.auth-required a {
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
.auth-required a:hover {
text-decoration: underline;
}
.login-footer {
margin-top: 2rem;
text-align: center;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.login-footer a {
color: #6b7280;
text-decoration: none;
}
.login-footer a:hover {
color: #2563eb;
text-decoration: underline;
}

222
frontend/css/courses.css Normal file
View File

@@ -0,0 +1,222 @@
/* Courses-specific styles */
.courses-page h1 {
margin-bottom: 2rem;
color: #1f2937;
}
.back-link {
display: inline-block;
margin-bottom: 2rem;
color: #6b7280;
text-decoration: none;
}
.back-link:hover {
color: #2563eb;
text-decoration: underline;
}
.course-detail-page {
background-color: white;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.course-header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
.course-header h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #1f2937;
}
.course-meta {
display: flex;
gap: 1.5rem;
align-items: center;
}
.course-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 3rem;
}
.course-description h3 {
margin-bottom: 1rem;
color: #374151;
}
.course-description p {
font-size: 1.1rem;
line-height: 1.8;
color: #4b5563;
}
.course-actions-panel {
background-color: #f9fafb;
padding: 1.5rem;
border-radius: 10px;
}
.action-group {
margin-bottom: 2rem;
}
.action-group h3 {
margin-bottom: 1rem;
color: #374151;
}
.action-group p {
margin-bottom: 1rem;
color: #6b7280;
}
.progress-display {
background-color: white;
padding: 1rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.auth-prompt {
background-color: #eff6ff;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #dbeafe;
margin-top: 2rem;
}
.auth-prompt p {
color: #1e40af;
}
.auth-prompt a {
color: #2563eb;
font-weight: 600;
text-decoration: none;
}
.auth-prompt a:hover {
text-decoration: underline;
}
/* Progress page */
.progress-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-bottom: 3rem;
}
.progress-item {
background-color: white;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.progress-header h3 {
margin: 0;
color: #1f2937;
}
.progress-percentage {
font-size: 1.5rem;
font-weight: bold;
color: #2563eb;
}
.progress-details {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
}
.progress-details span {
color: #6b7280;
}
.btn-update-progress {
background: none;
border: 1px solid #d1d5db;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
color: #4b5563;
transition: background-color 0.3s;
}
.btn-update-progress:hover {
background-color: #f3f4f6;
}
/* Available courses */
.available-courses {
margin-top: 3rem;
padding-top: 2rem;
border-top: 2px solid #e5e7eb;
}
.available-courses h2 {
margin-bottom: 1.5rem;
color: #1f2937;
}
.available-courses .courses-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
.course-card.small {
text-align: center;
}
.course-card.small h4 {
margin-bottom: 0.5rem;
color: #1f2937;
}
.btn-start-course {
width: 100%;
margin-top: 1rem;
background-color: #2563eb;
color: white;
border: none;
padding: 0.75rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.btn-start-course:hover {
background-color: #1d4ed8;
}
/* Favorites page */
.btn-remove-favorite {
background: none;
border: 1px solid #fca5a5;
color: #dc2626;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s;
}
.btn-remove-favorite:hover {
background-color: #fef2f2;
}

1201
frontend/css/style.css Normal file

File diff suppressed because it is too large Load Diff

67
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,67 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html/public;
index index.html;
client_max_body_size 60M;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Static files - CSS
location /css/ {
alias /usr/share/nginx/html/css/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Static files - JS
location /src/ {
alias /usr/share/nginx/html/src/;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# SPA routing - all routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
# API proxy to backend
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Uploaded files proxy to backend
location /uploads/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache uploaded files
expires 1y;
add_header Cache-Control "public, immutable";
}
# Other static files cache (excluding .css and .js which are handled above)
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LearnMD — Умное корпоративное обучение</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/auth.css">
<link rel="stylesheet" href="/css/courses.css">
<link rel="stylesheet" href="/src/styles/course-viewer.css">
<link rel="stylesheet" href="/src/styles/lesson-editor.css">
<link rel="stylesheet" href="/src/styles/pdf-viewer.css">
<link rel="stylesheet" href="/src/styles/test-editor.css">
<link rel="stylesheet" href="/src/styles/test-viewer.css">
<link rel="stylesheet" href="/src/styles/landing.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap" as="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/vditor@3.10.4/dist/index.css" />
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

341
frontend/src/api/courses.js Normal file
View File

@@ -0,0 +1,341 @@
import { apiRequest, getAuthToken } from '../utils/api.js';
export const coursesAPI = {
async getAll(level = null) {
const url = level ? `/api/courses?level=${level}` : '/api/courses';
const response = await apiRequest(url);
return response.data?.courses || [];
},
async getById(id) {
const response = await apiRequest(`/api/courses/${id}`);
return response.data?.course || null;
},
async getFavorites() {
const token = getAuthToken();
if (!token) return [];
const response = await apiRequest('/api/favorites', {
headers: { 'Authorization': `Bearer ${token}` }
});
return response || [];
},
async addFavorite(courseId) {
const token = getAuthToken();
if (!token) throw new Error('Not authenticated');
const response = await apiRequest('/api/favorites', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ course_id: courseId })
});
return response;
},
async removeFavorite(courseId) {
const token = getAuthToken();
if (!token) throw new Error('Not authenticated');
const response = await apiRequest(`/api/favorites/${courseId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
return response;
},
async getProgress(courseId) {
const token = getAuthToken();
if (!token) return null;
try {
const response = await apiRequest(`/api/progress/${courseId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
return response.data || null;
} catch (error) {
if (error.status === 404) return null;
throw error;
}
},
async updateProgress(courseId, completedLessons, totalLessons) {
const token = getAuthToken();
if (!token) throw new Error('Not authenticated');
const response = await apiRequest(`/api/progress/${courseId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
completed_lessons: completedLessons,
total_lessons: totalLessons
})
});
return response.data || response;
},
async createCourse(courseData) {
const response = await apiRequest('/api/course-builder/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(courseData)
});
return response.data.course;
},
async saveDraft(draftData) {
const response = await apiRequest('/api/course-builder/drafts/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draftData)
});
return response.data.draft;
},
async getDrafts() {
const response = await apiRequest('/api/course-builder/drafts');
return response.data?.drafts || [];
},
async getDraft(draftId) {
const response = await apiRequest(`/api/course-builder/drafts/${draftId}`);
return response.data?.draft || null;
},
async updateDraft(draftId, draftData) {
const response = await apiRequest(`/api/course-builder/drafts/${draftId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draftData)
});
return response.data?.draft || null;
},
async publishDraft(draftId) {
const response = await apiRequest(`/api/course-builder/drafts/${draftId}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
return response.data?.course || null;
},
async deleteDraft(draftId) {
await apiRequest(`/api/course-builder/drafts/${draftId}`, {
method: 'DELETE'
});
},
async createModule(moduleData) {
const response = await apiRequest('/api/course-builder/modules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(moduleData)
});
return response.data.module;
},
async createLesson(lessonData) {
const response = await apiRequest('/api/course-builder/lessons', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(lessonData)
});
return response.data.lesson;
},
async createStep(stepData) {
const response = await apiRequest('/api/course-builder/steps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lesson_id: stepData.lesson_id,
step_type: stepData.type,
title: stepData.title,
content: stepData.content,
file_url: stepData.file_url,
order_index: stepData.order
})
});
return response.data?.step || null;
},
async updateStep(stepId, stepData) {
const response = await apiRequest(`/api/course-builder/steps/${stepId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
step_type: stepData.type,
title: stepData.title,
content: stepData.content,
file_url: stepData.file_url,
order_index: stepData.order
})
});
return response.data?.step || null;
},
async deleteStep(stepId) {
await apiRequest(`/api/course-builder/steps/${stepId}`, {
method: 'DELETE'
});
},
async updateModule(moduleId, moduleData) {
const response = await apiRequest(`/api/course-builder/modules/${moduleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(moduleData)
});
return response.data.module;
},
async deleteModule(moduleId) {
await apiRequest(`/api/course-builder/modules/${moduleId}`, {
method: 'DELETE'
});
},
async updateLesson(lessonId, lessonData) {
const response = await apiRequest(`/api/course-builder/lessons/${lessonId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(lessonData)
});
return response.data.lesson;
},
async deleteLesson(lessonId) {
await apiRequest(`/api/course-builder/lessons/${lessonId}`, {
method: 'DELETE'
});
},
async getCourseStructure(courseId) {
const response = await apiRequest(`/api/course-builder/courses/${courseId}/structure`);
return response.data.course || null;
},
async updateCourse(courseId, courseData) {
const response = await apiRequest(`/api/courses/${courseId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(courseData)
});
return response.data.course;
},
async getCourseForLearning(courseId) {
const response = await apiRequest(`/api/courses/${courseId}/learn`);
return response.data?.course || null;
},
async getCourseStructure(courseId) {
const response = await apiRequest(`/api/courses/${courseId}/structure`);
return response.data?.course || null;
},
async getStepContent(stepId) {
const response = await apiRequest(`/api/learn/steps/${stepId}`);
return response.data?.step || null;
},
async uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
const token = getAuthToken();
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${window.location.origin}/api/course-builder/upload`, {
method: 'POST',
headers,
body: formData
});
if (!response.ok) {
const text = await response.text();
let errorMsg = 'Upload failed';
try {
const error = JSON.parse(text);
errorMsg = error.detail || errorMsg;
} catch {
errorMsg = text.substring(0, 200) || `HTTP ${response.status}`;
}
throw new Error(errorMsg);
}
const data = await response.json();
return data.data || null;
},
async getLesson(lessonId) {
const response = await apiRequest(`/api/course-builder/lessons/${lessonId}`);
return response.data?.lesson || null;
},
// Test API methods
async getTestQuestions(stepId) {
const response = await apiRequest(`/api/course-builder/steps/${stepId}/test/questions`);
return response.data || null;
},
async createTestQuestion(stepId, questionData) {
const response = await apiRequest(`/api/course-builder/steps/${stepId}/test/questions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(questionData)
});
return response.data?.question || null;
},
async updateTestQuestion(stepId, questionId, questionData) {
const response = await apiRequest(`/api/course-builder/steps/${stepId}/test/questions/${questionId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(questionData)
});
return response.data?.question || null;
},
async deleteTestQuestion(stepId, questionId) {
await apiRequest(`/api/course-builder/steps/${stepId}/test/questions/${questionId}`, {
method: 'DELETE'
});
},
async updateTestSettings(stepId, settings) {
const response = await apiRequest(`/api/course-builder/steps/${stepId}/test/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
return response.data?.settings || null;
},
async getTestForViewing(stepId) {
const response = await apiRequest(`/api/learn/steps/${stepId}/test`);
return response.data || null;
},
async submitTest(stepId, answers) {
const response = await apiRequest(`/api/learn/steps/${stepId}/test/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ step_id: stepId, answers })
});
return response.data || null;
},
async getTestAttempts(stepId) {
const response = await apiRequest(`/api/learn/steps/${stepId}/test/attempts`);
return response.data?.attempts || [];
}
};

View File

@@ -0,0 +1,327 @@
export class PdfViewer {
constructor(container, pdfUrl, options = {}) {
this.container = container;
this.pdfUrl = pdfUrl;
this.options = {
showControls: true,
fullscreenButton: true,
...options
};
this.pdfDoc = null;
this.currentPage = 1;
this.totalPages = 0;
this.scale = 1.5;
this.isFullscreen = false;
this.canvas = null;
this.ctx = null;
}
async init() {
if (typeof pdfjsLib === 'undefined') {
console.error('PDF.js not loaded');
this.showError('PDF.js library not loaded');
return;
}
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
this.render();
try {
const loadingTask = pdfjsLib.getDocument(this.pdfUrl);
loadingTask.onProgress = (progress) => {
if (progress.total) {
const percent = Math.round((progress.loaded / progress.total) * 100);
this.updateLoadingProgress(percent);
}
};
this.pdfDoc = await loadingTask.promise;
this.totalPages = this.pdfDoc.numPages;
this.renderControls();
await this.renderPage(this.currentPage);
} catch (error) {
console.error('Error loading PDF:', error);
this.showError(`Ошибка загрузки PDF: ${error.message}`);
}
}
render() {
this.container.innerHTML = `
<div class="pdf-viewer">
<div class="pdf-loading" id="pdf-loading">
<div class="pdf-spinner"></div>
<p>Загрузка PDF... <span id="pdf-progress">0%</span></p>
</div>
<div class="pdf-content" style="display: none;">
<div class="pdf-controls" id="pdf-controls"></div>
<div class="pdf-progress-bar">
<div class="pdf-progress-fill" id="pdf-progress-fill"></div>
</div>
<div class="pdf-canvas-container">
<canvas id="pdf-canvas"></canvas>
</div>
</div>
<div class="pdf-error" id="pdf-error" style="display: none;"></div>
</div>
`;
this.canvas = this.container.querySelector('#pdf-canvas');
this.ctx = this.canvas.getContext('2d');
}
renderControls() {
const controls = this.container.querySelector('#pdf-controls');
if (!controls || !this.options.showControls) return;
controls.innerHTML = `
<div class="pdf-nav">
<button class="pdf-btn" id="pdf-prev" title="Предыдущая страница (←)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M15 18l-6-6 6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<span class="pdf-page-info">
<span id="pdf-current-page">${this.currentPage}</span> / <span id="pdf-total-pages">${this.totalPages}</span>
</span>
<button class="pdf-btn" id="pdf-next" title="Следующая страница (→)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M9 18l6-6-6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="pdf-zoom">
<button class="pdf-btn" id="pdf-zoom-out" title="Уменьшить">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8" stroke-width="2"/>
<path d="M21 21l-4.35-4.35M8 11h6" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<span class="pdf-zoom-level" id="pdf-zoom-level">${Math.round(this.scale * 100)}%</span>
<button class="pdf-btn" id="pdf-zoom-in" title="Увеличить">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8" stroke-width="2"/>
<path d="M21 21l-4.35-4.35M8 11h6M11 8v6" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
${this.options.fullscreenButton ? `
<button class="pdf-btn pdf-fullscreen-btn" id="pdf-fullscreen" title="Полноэкранный режим">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
` : ''}
`;
controls.style.display = 'flex';
controls.style.flexDirection = 'row';
controls.style.alignItems = 'center';
controls.style.justifyContent = 'center';
controls.style.gap = '24px';
controls.style.flexWrap = 'nowrap';
this.attachControlListeners();
this.hideLoading();
}
attachControlListeners() {
const prevBtn = this.container.querySelector('#pdf-prev');
const nextBtn = this.container.querySelector('#pdf-next');
const zoomInBtn = this.container.querySelector('#pdf-zoom-in');
const zoomOutBtn = this.container.querySelector('#pdf-zoom-out');
const fullscreenBtn = this.container.querySelector('#pdf-fullscreen');
prevBtn?.addEventListener('click', () => this.prevPage());
nextBtn?.addEventListener('click', () => this.nextPage());
zoomInBtn?.addEventListener('click', () => this.zoomIn());
zoomOutBtn?.addEventListener('click', () => this.zoomOut());
fullscreenBtn?.addEventListener('click', () => this.toggleFullscreen());
document.addEventListener('keydown', (e) => this.handleKeydown(e));
}
async renderPage(pageNum) {
if (!this.pdfDoc) return;
const page = await this.pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: this.scale });
this.canvas.height = viewport.height;
this.canvas.width = viewport.width;
const renderContext = {
canvasContext: this.ctx,
viewport: viewport
};
await page.render(renderContext).promise;
this.updatePageDisplay();
}
async prevPage() {
if (this.currentPage <= 1) return;
this.currentPage--;
await this.renderPage(this.currentPage);
}
async nextPage() {
if (this.currentPage >= this.totalPages) return;
this.currentPage++;
await this.renderPage(this.currentPage);
}
async goToPage(pageNum) {
if (pageNum < 1 || pageNum > this.totalPages) return;
this.currentPage = pageNum;
await this.renderPage(this.currentPage);
}
async zoomIn() {
if (this.scale >= 3) return;
this.scale += 0.25;
await this.renderPage(this.currentPage);
this.updateZoomDisplay();
}
async zoomOut() {
if (this.scale <= 0.5) return;
this.scale -= 0.25;
await this.renderPage(this.currentPage);
this.updateZoomDisplay();
}
toggleFullscreen() {
const viewer = this.container.querySelector('.pdf-viewer');
if (!this.isFullscreen) {
if (viewer.requestFullscreen) {
viewer.requestFullscreen();
} else if (viewer.webkitRequestFullscreen) {
viewer.webkitRequestFullscreen();
} else if (viewer.msRequestFullscreen) {
viewer.msRequestFullscreen();
}
viewer.classList.add('fullscreen');
this.isFullscreen = true;
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
viewer.classList.remove('fullscreen');
this.isFullscreen = false;
}
}
handleKeydown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
switch (e.key) {
case 'ArrowLeft':
case 'PageUp':
e.preventDefault();
this.prevPage();
break;
case 'ArrowRight':
case 'PageDown':
case ' ': // Space
e.preventDefault();
this.nextPage();
break;
case 'Home':
e.preventDefault();
this.goToPage(1);
break;
case 'End':
e.preventDefault();
this.goToPage(this.totalPages);
break;
case '+':
case '=':
e.preventDefault();
this.zoomIn();
break;
case '-':
e.preventDefault();
this.zoomOut();
break;
case 'f':
case 'F':
e.preventDefault();
this.toggleFullscreen();
break;
case 'Escape':
if (this.isFullscreen) {
this.toggleFullscreen();
}
break;
}
}
updatePageDisplay() {
const currentPageEl = this.container.querySelector('#pdf-current-page');
if (currentPageEl) {
currentPageEl.textContent = this.currentPage;
}
const progressFill = this.container.querySelector('#pdf-progress-fill');
if (progressFill && this.totalPages > 0) {
const progress = (this.currentPage / this.totalPages) * 100;
progressFill.style.width = `${progress}%`;
}
}
updateZoomDisplay() {
const zoomLevelEl = this.container.querySelector('#pdf-zoom-level');
if (zoomLevelEl) {
zoomLevelEl.textContent = `${Math.round(this.scale * 100)}%`;
}
}
updateLoadingProgress(percent) {
const progressEl = this.container.querySelector('#pdf-progress');
if (progressEl) {
progressEl.textContent = `${percent}%`;
}
}
hideLoading() {
const loading = this.container.querySelector('#pdf-loading');
const content = this.container.querySelector('.pdf-content');
if (loading) loading.style.display = 'none';
if (content) content.style.display = 'block';
}
showError(message) {
const loading = this.container.querySelector('#pdf-loading');
const error = this.container.querySelector('#pdf-error');
if (loading) loading.style.display = 'none';
if (error) {
error.style.display = 'block';
error.innerHTML = `
<div class="pdf-error-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#ef4444">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
<path d="M15 9l-6 6M9 9l6 6" stroke-width="2" stroke-linecap="round"/>
</svg>
<p>${message}</p>
</div>
`;
}
}
destroy() {
document.removeEventListener('keydown', this.handleKeydown);
if (this.pdfDoc) {
this.pdfDoc.destroy();
}
}
}

View File

@@ -0,0 +1,368 @@
import { coursesAPI } from '../api/courses.js';
export class TestViewer {
constructor(container, stepId) {
this.container = container;
this.stepId = stepId;
this.questions = [];
this.settings = {};
this.attempts = [];
this.currentQuestionIndex = 0;
this.answers = {};
this.timeRemaining = 0;
this.timerInterval = null;
this.isSubmitted = false;
}
async load() {
try {
const response = await coursesAPI.getTestForViewing(this.stepId);
if (response) {
this.questions = response.questions || [];
this.settings = response.settings || {};
this.attempts = response.attempts || [];
// Initialize answers object
this.questions.forEach(q => {
if (q.question_type === 'single_choice') {
this.answers[q.id] = null;
} else if (q.question_type === 'multiple_choice') {
this.answers[q.id] = [];
} else if (q.question_type === 'text_answer') {
this.answers[q.id] = '';
} else if (q.question_type === 'match') {
this.answers[q.id] = {};
}
});
// Start timer if time limit is set
if (this.settings.time_limit > 0) {
this.timeRemaining = this.settings.time_limit * 60;
this.startTimer();
}
this.render();
}
} catch (error) {
console.error('Failed to load test:', error);
this.showError('Ошибка загрузки теста: ' + error.message);
}
}
startTimer() {
this.updateTimerDisplay();
this.timerInterval = setInterval(() => {
this.timeRemaining--;
this.updateTimerDisplay();
if (this.timeRemaining <= 0) {
clearInterval(this.timerInterval);
this.submitTest();
}
}, 1000);
}
updateTimerDisplay() {
const timerEl = document.getElementById('test-timer');
if (timerEl) {
const minutes = Math.floor(this.timeRemaining / 60);
const seconds = this.timeRemaining % 60;
timerEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
if (this.timeRemaining < 300) { // Less than 5 minutes
timerEl.classList.add('warning');
}
}
}
render() {
if (this.isSubmitted) {
return this.renderResults();
}
const question = this.questions[this.currentQuestionIndex];
this.container.innerHTML = `
<div class="test-viewer">
<div class="test-header">
<div class="test-info">
<span class="question-counter">Вопрос ${this.currentQuestionIndex + 1} из ${this.questions.length}</span>
${this.settings.time_limit > 0 ? `
<span class="test-timer" id="test-timer">
${Math.floor(this.timeRemaining / 60)}:${(this.timeRemaining % 60).toString().padStart(2, '0')}
</span>
` : ''}
</div>
<div class="test-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: ${((this.currentQuestionIndex + 1) / this.questions.length * 100)}%"></div>
</div>
</div>
</div>
<div class="question-container">
${this.renderQuestion(question)}
</div>
<div class="test-navigation">
<button class="btn btn-outline" onclick="window.prevQuestion()" ${this.currentQuestionIndex === 0 ? 'disabled' : ''}>
← Назад
</button>
<div class="question-dots">
${this.questions.map((q, idx) => `
<button class="dot ${idx === this.currentQuestionIndex ? 'active' : ''} ${this.answers[q.id] ? 'answered' : ''}"
onclick="window.goToQuestion(${idx})">
${idx + 1}
</button>
`).join('')}
</div>
${this.currentQuestionIndex < this.questions.length - 1 ? `
<button class="btn btn-primary" onclick="window.nextQuestion()">
Далее →
</button>
` : `
<button class="btn btn-success" onclick="window.submitTest()">
Завершить тест
</button>
`}
</div>
</div>
`;
}
renderQuestion(question) {
if (!question) return '<p>Вопрос не найден</p>';
return `
<div class="question" data-question-id="${question.id}">
<div class="question-header">
<span class="question-points">${question.points} балл(ов)</span>
</div>
<div class="question-text">${question.question_text}</div>
<div class="question-answers">
${this.renderAnswers(question)}
</div>
</div>
`;
}
renderAnswers(question) {
switch (question.question_type) {
case 'single_choice':
return this.renderSingleChoice(question);
case 'multiple_choice':
return this.renderMultipleChoice(question);
case 'text_answer':
return this.renderTextAnswer(question);
case 'match':
return this.renderMatch(question);
default:
return '<p>Неизвестный тип вопроса</p>';
}
}
renderSingleChoice(question) {
return `
<div class="answers-list single-choice">
${question.answers.map(answer => `
<label class="answer-option ${this.answers[question.id] === answer.id ? 'selected' : ''}">
<input type="radio" name="question-${question.id}" value="${answer.id}"
${this.answers[question.id] === answer.id ? 'checked' : ''}
onchange="window.selectAnswer(${question.id}, ${answer.id})">
<span class="answer-text">${answer.answer_text}</span>
</label>
`).join('')}
</div>
`;
}
renderMultipleChoice(question) {
return `
<div class="answers-list multiple-choice">
${question.answers.map(answer => `
<label class="answer-option ${this.answers[question.id]?.includes(answer.id) ? 'selected' : ''}">
<input type="checkbox" value="${answer.id}"
${this.answers[question.id]?.includes(answer.id) ? 'checked' : ''}
onchange="window.toggleAnswer(${question.id}, ${answer.id})">
<span class="answer-text">${answer.answer_text}</span>
</label>
`).join('')}
</div>
`;
}
renderTextAnswer(question) {
return `
<div class="text-answer">
<textarea class="form-control" rows="4"
placeholder="Введите ваш ответ..."
onchange="window.setTextAnswer(${question.id}, this.value)">${this.answers[question.id] || ''}</textarea>
</div>
`;
}
renderMatch(question) {
// Shuffle right items for display
const rightItems = [...question.match_items].sort(() => Math.random() - 0.5);
return `
<div class="match-question">
<div class="match-columns">
<div class="match-column left-column">
${question.match_items.map(item => `
<div class="match-item" data-id="${item.id}">
<span>${item.left_text}</span>
<select onchange="window.selectMatch(${question.id}, ${item.id}, this.value)">
<option value="">Выберите...</option>
${rightItems.map(r => `
<option value="${r.right_text}" ${this.answers[question.id]?.[item.id] === r.right_text ? 'selected' : ''}>
${r.right_text}
</option>
`).join('')}
</select>
</div>
`).join('')}
</div>
</div>
</div>
`;
}
renderResults() {
const lastAttempt = this.attempts[0];
if (!lastAttempt) return '<p>Результаты не найдены</p>';
const percentage = Math.round((lastAttempt.score / lastAttempt.max_score) * 100);
return `
<div class="test-results">
<div class="results-header ${lastAttempt.passed ? 'passed' : 'failed'}">
<h2>${lastAttempt.passed ? '✓ Тест пройден!' : '✗ Тест не пройден'}</h2>
<div class="score-circle">
<span class="score-value">${percentage}%</span>
</div>
</div>
<div class="results-details">
<p>Набрано баллов: ${lastAttempt.score} из ${lastAttempt.max_score}</p>
<p>Проходной балл: ${this.settings.passing_score}%</p>
</div>
${this.settings.max_attempts > 0 ? `
<p class="attempts-info">Попыток использовано: ${this.attempts.length} из ${this.settings.max_attempts}</p>
` : ''}
${!lastAttempt.passed && (this.settings.max_attempts === 0 || this.attempts.length < this.settings.max_attempts) ? `
<button class="btn btn-primary" onclick="window.location.reload()">
Попробовать снова
</button>
` : ''}
</div>
`;
}
showError(message) {
this.container.innerHTML = `
<div class="test-error">
<p>${message}</p>
</div>
`;
}
async submitTest() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
}
// Validate all questions are answered
const unanswered = this.questions.filter(q => {
const answer = this.answers[q.id];
if (q.question_type === 'single_choice') return answer === null;
if (q.question_type === 'multiple_choice') return answer.length === 0;
if (q.question_type === 'text_answer') return !answer.trim();
if (q.question_type === 'match') return Object.keys(answer).length === 0;
return false;
});
if (unanswered.length > 0 && !confirm(`Осталось ${unanswered.length} без ответа. Продолжить?`)) {
return;
}
try {
const response = await coursesAPI.submitTest(this.stepId, this.answers);
if (response && response.attempt) {
this.attempts.unshift(response.attempt);
this.isSubmitted = true;
this.container.innerHTML = this.renderResults();
}
} catch (error) {
alert('Ошибка отправки теста: ' + error.message);
}
}
destroy() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
}
}
}
// Global functions
window.prevQuestion = function() {
if (!window.currentTestViewer) return;
if (window.currentTestViewer.currentQuestionIndex > 0) {
window.currentTestViewer.currentQuestionIndex--;
window.currentTestViewer.render();
}
};
window.nextQuestion = function() {
if (!window.currentTestViewer) return;
if (window.currentTestViewer.currentQuestionIndex < window.currentTestViewer.questions.length - 1) {
window.currentTestViewer.currentQuestionIndex++;
window.currentTestViewer.render();
}
};
window.goToQuestion = function(index) {
if (!window.currentTestViewer) return;
window.currentTestViewer.currentQuestionIndex = index;
window.currentTestViewer.render();
};
window.selectAnswer = function(questionId, answerId) {
if (!window.currentTestViewer) return;
window.currentTestViewer.answers[questionId] = answerId;
};
window.toggleAnswer = function(questionId, answerId) {
if (!window.currentTestViewer) return;
const answers = window.currentTestViewer.answers[questionId];
const index = answers.indexOf(answerId);
if (index > -1) {
answers.splice(index, 1);
} else {
answers.push(answerId);
}
};
window.setTextAnswer = function(questionId, text) {
if (!window.currentTestViewer) return;
window.currentTestViewer.answers[questionId] = text;
};
window.selectMatch = function(questionId, leftId, rightValue) {
if (!window.currentTestViewer) return;
if (!window.currentTestViewer.answers[questionId]) {
window.currentTestViewer.answers[questionId] = {};
}
window.currentTestViewer.answers[questionId][leftId] = rightValue;
};
window.submitTest = function() {
if (!window.currentTestViewer) return;
window.currentTestViewer.submitTest();
};

View File

@@ -0,0 +1,141 @@
import { apiRequest, setAuthToken, removeAuthToken, getAuthToken } from '../utils/api.js';
import { router } from '../router.js';
export let currentUser = null;
export async function initAuth() {
const token = getAuthToken();
if (token) {
try {
const response = await apiRequest('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
currentUser = response.data || null;
console.log('User authenticated:', currentUser?.email, 'is_developer:', currentUser?.is_developer);
} catch (error) {
console.error('Auth check failed:', error);
removeAuthToken();
currentUser = null;
}
}
updateAuthUI();
}
export async function login(email, password) {
try {
const response = await apiRequest('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
setAuthToken(response.data?.access_token);
localStorage.setItem('refresh_token', response.data?.refresh_token);
await initAuth();
return { success: true };
} catch (error) {
console.error('Login failed:', error);
return { success: false, error: error.data?.detail || 'Ошибка входа' };
}
}
export async function register(email, password) {
try {
await apiRequest('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password })
});
return { success: true };
} catch (error) {
console.error('Registration failed:', error);
return { success: false, error: error.data?.detail || 'Ошибка регистрации' };
}
}
export async function logout() {
const token = getAuthToken();
if (token) {
try {
await apiRequest('/api/auth/logout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
} catch (error) {
console.error('Logout error:', error);
}
}
removeAuthToken();
localStorage.removeItem('refresh_token');
currentUser = null;
updateAuthUI();
router.navigate('/');
}
export async function refreshAccessToken() {
const token = getAuthToken();
if (!token) return false;
try {
const response = await apiRequest('/api/auth/refresh', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
_isRetry: true
});
setAuthToken(response.data?.access_token);
localStorage.setItem('refresh_token', response.data?.refresh_token);
return true;
} catch (error) {
console.error('Refresh failed:', error);
return false;
}
}
export function updateAuthUI() {
console.log('updateAuthUI called, currentUser:', currentUser);
// Update navigation links
const navLinks = document.querySelector('.nav-links');
if (navLinks) {
console.log('Updating navigation links');
navLinks.innerHTML = `
<a href="/" data-link>Главная</a>
<a href="/courses" data-link>Курсы</a>
${currentUser ? `
${currentUser.is_developer ? `
<div class="nav-dropdown">
<button class="nav-dropdown-btn">Инструменты ▼</button>
<div class="nav-dropdown-content">
<a href="/dev-tools" data-link>Разработка</a>
</div>
</div>
` : ''}
<a href="/favorites" data-link>Избранное</a>
<a href="/progress" data-link>Мой прогресс</a>
<a href="/course-builder" data-link>Конструктор курса</a>
` : ''}
`;
} else {
console.log('nav-links element not found');
}
// Update auth button
const authBtn = document.getElementById('auth-btn');
console.log('Auth button:', authBtn);
if (!authBtn) return;
if (currentUser) {
authBtn.textContent = currentUser.email;
authBtn.onclick = () => {
console.log('Logout clicked');
logout();
};
} else {
authBtn.textContent = 'Войти';
authBtn.onclick = () => {
console.log('Login clicked, navigating to /login');
router.navigate('/login');
};
}
}

View File

@@ -0,0 +1,48 @@
import { currentUser } from './auth.js';
function removeExistingNav() {
const existingNav = document.querySelector('.main-nav');
if (existingNav) {
existingNav.remove();
}
}
export function renderNav() {
removeExistingNav();
const nav = document.createElement('nav');
nav.className = 'main-nav';
nav.innerHTML = `
<div class="nav-container">
<div class="nav-brand">
<a href="/" data-link>Изучение аутентификации</a>
</div>
<div class="nav-links">
<a href="/" data-link>Главная</a>
<a href="/courses" data-link>Курсы</a>
${currentUser ? `
${currentUser.is_developer ? `
<div class="nav-dropdown">
<button class="nav-dropdown-btn">Инструменты ▼</button>
<div class="nav-dropdown-content">
<a href="/dev-tools" data-link>Разработка</a>
</div>
</div>
` : ''}
<a href="/favorites" data-link>Избранное</a>
<a href="/progress" data-link>Мой прогресс</a>
<a href="/course-builder" data-link>Конструктор курса</a>
` : ''}
</div>
<div class="nav-auth">
<button id="auth-btn" class="auth-btn">Войти</button>
</div>
</div>
`;
document.body.insertBefore(nav, document.body.firstChild);
}
export function updateNav() {
renderNav();
}

View File

@@ -0,0 +1,16 @@
export const stepTypeLabels = {
'theory': 'Теоретический материал',
'practice': 'Практическое задание',
'lab': 'Лабораторная работа',
'test': 'Тест'
};
export function getStepTypeLabel(type) {
return stepTypeLabels[type] || type;
}
export const validationRules = {
courseTitle: { minLength: 3, maxLength: 255 },
moduleTitle: { maxLength: 255 },
maxModules: 50
};

View File

@@ -0,0 +1,25 @@
export function showMessage(text, type) {
const messageEl = document.getElementById('builder-message');
if (!messageEl) {
console.warn('Message element not found');
return;
}
messageEl.textContent = text;
messageEl.className = `message ${type}`;
}
export function updateBuilderTitle(isEditing) {
const h2 = document.querySelector('.course-builder-container h2');
const subtitle = document.querySelector('.course-builder-container .subtitle');
const publishBtn = document.getElementById('publish-course');
if (isEditing) {
if (h2) h2.textContent = 'Редактирование курса';
if (subtitle) subtitle.textContent = 'Редактируйте содержимое курса с помощью редактора Markdown';
if (publishBtn) publishBtn.textContent = 'Сохранить изменения';
} else {
if (h2) h2.textContent = 'Конструктор курса';
if (subtitle) subtitle.textContent = 'Создайте новый курс с помощью редактора Markdown с поддержкой LaTeX';
if (publishBtn) publishBtn.textContent = 'Опубликовать курс';
}
}

View File

@@ -0,0 +1,263 @@
import { coursesAPI } from '../api/courses.js';
import { validationRules } from './Constants.js';
import { getVditorInstance, currentCourseId, isEditMode, isDraftMode, courseStructure, setCurrentCourseId, setIsEditMode, setIsDraftMode, setPendingDraftContent } from './State.js';
import { showMessage } from './CourseUI.js';
import { router } from '../router.js';
export function saveDraft() {
const titleEl = document.getElementById('course-title');
const descriptionEl = document.getElementById('course-description');
const levelEl = document.getElementById('course-level');
if (!titleEl || !descriptionEl || !levelEl) return;
const title = titleEl.value;
const description = descriptionEl.value;
const level = levelEl.value;
const vditor = getVditorInstance();
const content = vditor ? vditor.getValue() : '';
if (!title.trim()) { showMessage('❌ Введите название курса', 'error'); return; }
const localDraft = { title, description, level, content, structure: courseStructure, savedAt: new Date().toISOString() };
localStorage.setItem('course_draft', JSON.stringify(localDraft));
showMessage('💾 Автосохранение...', 'info');
if (currentCourseId) {
const savePromise = isDraftMode
? coursesAPI.updateDraft(currentCourseId, { title, description, level, content })
: coursesAPI.updateCourse(currentCourseId, { title, description, level, content });
savePromise
.then(() => showMessage(`✅ Сохранено (${new Date().toLocaleTimeString()})`, 'success'))
.catch(error => { console.error('Failed to save:', error); showMessage('⚠️ Сохранено только локально', 'warning'); });
} else {
coursesAPI.createCourse({ title, description, level, content, structure: courseStructure })
.then(course => { setCurrentCourseId(course.id); showMessage(`✅ Черновик создан (${new Date().toLocaleTimeString()})`, 'success'); })
.catch(error => { console.error('Failed to create draft:', error); showMessage('❌ Ошибка при создании черновика', 'error'); });
}
}
export function previewCourse() {
const vditor = getVditorInstance();
if (vditor) vditor.setPreviewMode(vditor.vditor.preview.element.style.display === 'none');
}
export async function publishCourse() {
const title = document.getElementById('course-title').value;
const vditor = getVditorInstance();
const content = vditor ? vditor.getValue() : '';
if (!title.trim()) { showMessage('❌ Введите название курса', 'error'); return; }
if (!content.trim()) { showMessage('❌ Добавьте содержание курса', 'error'); return; }
if (title.length < validationRules.courseTitle.minLength) { showMessage(`❌ Название слишком короткое (минимум ${validationRules.courseTitle.minLength} символа)`, 'error'); return; }
if (title.length > validationRules.courseTitle.maxLength) { showMessage(`❌ Название слишком длинное (максимум ${validationRules.courseTitle.maxLength} символов)`, 'error'); return; }
const validation = validateCourseStructure(courseStructure);
if (!validation.valid) { showMessage('❌ ' + validation.error, 'error'); return; }
showMessage('📤 Публикация курса...', 'info');
try {
let course;
if (currentCourseId) {
if (isDraftMode) {
course = await coursesAPI.publishDraft(currentCourseId);
showMessage('✅ Курс успешно опубликован!', 'success');
} else {
await coursesAPI.updateCourse(currentCourseId, { title, description: document.getElementById('course-description').value, level: document.getElementById('course-level').value, content });
course = { id: currentCourseId };
showMessage('✅ Курс успешно обновлен!', 'success');
}
} else {
course = await coursesAPI.createCourse({ title, description: document.getElementById('course-description').value, level: document.getElementById('course-level').value, content, structure: courseStructure });
course = await coursesAPI.publishDraft(course.id);
showMessage('✅ Курс успешно создан и опубликован!', 'success');
}
router.navigate(`/courses/${course.id}`);
} catch (error) {
console.error('Failed to publish course:', error);
showMessage('❌ Ошибка при публикации: ' + (error.data?.detail || 'Неизвестная ошибка'), 'error');
}
}
export function validateCourseStructure(structure) {
if (!structure.modules || structure.modules.length === 0) return { valid: false, error: 'Добавьте хотя бы один модуль в курс' };
let totalLessons = 0;
for (const module of structure.modules) {
if (!module.title || module.title.trim().length === 0) return { valid: false, error: 'Все модули должны иметь название' };
if (module.title.length > validationRules.moduleTitle.maxLength) return { valid: false, error: `Название модуля слишком длинное (максимум ${validationRules.moduleTitle.maxLength} символов)` };
if (module.lessons && module.lessons.length > 0) {
totalLessons += module.lessons.length;
for (const lesson of module.lessons) {
if (!lesson.title || lesson.title.trim().length === 0) return { valid: false, error: 'Все уроки должны иметь название' };
}
}
}
if (totalLessons === 0) return { valid: false, error: 'Добавьте хотя бы один урок в любой модуль' };
if (structure.modules.length > validationRules.maxModules) return { valid: false, error: `Максимум ${validationRules.maxModules} модулей в курсе` };
return { valid: true };
}
export async function updateDraftsCount() {
try {
const drafts = await coursesAPI.getDrafts();
const countEl = document.getElementById('drafts-count');
if (countEl) countEl.textContent = drafts.length;
} catch (error) { console.error('Failed to load drafts count:', error); }
}
export async function showDraftsModal() {
try {
const drafts = await coursesAPI.getDrafts();
const modalHtml = `
<div class="modal-overlay" id="drafts-modal">
<div class="modal" style="max-width: 700px; max-height: 80vh; overflow-y: auto;">
<div class="modal-header"><h3>Мои черновики</h3></div>
<div class="modal-body">
${drafts.length === 0 ? '<p class="no-drafts">У вас пока нет черновиков</p>' : `
<div class="drafts-list">
${drafts.map(draft => `
<div class="draft-item" data-draft-id="${draft.id}">
<div class="draft-info">
<h4 class="draft-title">${draft.title}</h4>
<p class="draft-meta">Уровень: ${draft.level || 'Не указан'} | Обновлено: ${new Date(draft.updated_at).toLocaleDateString('ru-RU')}</p>
${draft.description ? `<p class="draft-desc">${draft.description}</p>` : ''}
</div>
<div class="draft-actions">
<button type="button" class="btn btn-sm btn-primary load-draft" data-draft-id="${draft.id}">Загрузить</button>
<button type="button" class="btn btn-sm btn-outline edit-draft" data-draft-id="${draft.id}">Редактировать</button>
<button type="button" class="btn btn-sm btn-secondary delete-draft" data-draft-id="${draft.id}">🗑️</button>
</div>
</div>
`).join('')}
</div>
`}
</div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" id="close-drafts-modal">Закрыть</button></div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById('drafts-modal');
document.getElementById('close-drafts-modal').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
modal.querySelectorAll('.load-draft').forEach(btn => {
btn.addEventListener('click', () => loadDraftIntoEditor(parseInt(btn.dataset.draftId)).then(() => modal.remove()));
});
modal.querySelectorAll('.edit-draft').forEach(btn => {
btn.addEventListener('click', () => {
const draftId = parseInt(btn.dataset.draftId);
modal.remove();
const newTitle = prompt('Введите новое название черновика:');
if (newTitle !== null) updateDraftTitle(draftId, newTitle);
});
});
modal.querySelectorAll('.delete-draft').forEach(btn => {
btn.addEventListener('click', async () => {
const draftId = parseInt(btn.dataset.draftId);
if (confirm('Удалить этот черновик?')) {
try {
await coursesAPI.deleteDraft(draftId);
modal.querySelector(`[data-draft-id="${draftId}"]`)?.remove();
updateDraftsCount();
showMessage('✅ Черновик удален', 'success');
if (modal.querySelectorAll('.draft-item').length === 0) modal.querySelector('.modal-body').innerHTML = '<p class="no-drafts">У вас пока нет черновиков</p>';
} catch (error) { console.error('Failed to delete draft:', error); showMessage('❌ Ошибка при удалении черновика', 'error'); }
}
});
});
} catch (error) { console.error('Failed to load drafts:', error); showMessage('❌ Не удалось загрузить черновики', 'error'); }
}
export async function updateDraftTitle(draftId, newTitle) {
try {
showMessage('⏳ Обновление названия...', 'info');
const draft = await coursesAPI.getDraft(draftId);
if (!draft) { showMessage('❌ Черновик не найден', 'error'); return; }
await coursesAPI.updateDraft(draftId, { title: newTitle, description: draft.description, level: draft.level, content: draft.content });
showMessage('✅ Название обновлено', 'success');
showDraftsModal();
} catch (error) { console.error('Failed to update draft:', error); showMessage('❌ Ошибка при обновлении', 'error'); }
}
export async function loadDraftIntoEditor(draftId) {
try {
const draft = await coursesAPI.getDraft(draftId);
if (!draft) { showMessage('❌ Черновик не найден', 'error'); return; }
setCurrentCourseId(draft.id);
setIsEditMode(true);
setIsDraftMode(true);
document.getElementById('course-title').value = draft.title || '';
document.getElementById('course-description').value = draft.description || '';
document.getElementById('course-level').value = draft.level || 'beginner';
setPendingDraftContent(draft.content || '');
if (draft.modules) {
courseStructure.modules = draft.modules.map(module => ({
id: module.id,
title: module.title,
duration: module.duration,
lessons: module.lessons ? module.lessons.map(lesson => ({
id: lesson.id,
title: lesson.title,
steps: lesson.steps ? lesson.steps.map(step => {
console.log('Loading step:', step.id, 'title:', step.title, 'type:', step.step_type);
return {
id: step.id,
type: step.step_type,
title: step.title,
content: step.content,
order: step.order_index
};
}) : []
})) : []
}));
}
const { renderCourseStructure } = await import('./StructureManager.js');
renderCourseStructure();
showMessage(`✅ Черновик загружен: ${draft.title}`, 'success');
return draft;
} catch (error) { console.error('Failed to load draft:', error); showMessage('❌ Не удалось загрузить черновик', 'error'); }
}
export function loadStructureFromDraft() {
const urlParams = new URLSearchParams(window.location.search);
const draftId = urlParams.get('draft');
if (draftId) {
loadDraftsFromServer().then(() => loadDraftIntoEditor(draftId));
} else {
const draft = localStorage.getItem('course_draft');
if (draft) {
try {
const data = JSON.parse(draft);
if (data.structure && data.structure.modules) courseStructure.modules = data.structure.modules;
} catch (e) { console.error('Error loading structure from draft:', e); }
}
}
}
export async function loadDraftsFromServer() {
try {
const drafts = await coursesAPI.getDrafts();
localStorage.setItem('drafts', JSON.stringify(drafts));
} catch (error) { console.error('Failed to load drafts from server:', error); }
}
export async function loadLastDraft() {
try {
const drafts = await coursesAPI.getDrafts();
if (drafts && drafts.length > 0) {
const lastDraft = drafts.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))[0];
return await loadDraftIntoEditor(lastDraft.id);
}
return null;
} catch (error) {
console.error('Failed to load last draft:', error);
return null;
}
}

View File

@@ -0,0 +1,158 @@
import { coursesAPI } from '../api/courses.js';
import { courseStructure } from './State.js';
import { saveDraft } from './DraftManager.js';
import { renderCourseStructure } from './StructureManager.js';
import { showMessage } from './CourseUI.js';
import { getStepTypeLabel } from './Constants.js';
export function showAddLessonModal(moduleIndex) {
const modalHtml = `
<div class="modal-overlay" id="add-lesson-modal">
<div class="modal">
<div class="modal-header"><h3>Добавить урок</h3></div>
<div class="modal-body">
<div class="form-group">
<label for="lesson-title">Название урока *</label>
<input type="text" id="lesson-title" placeholder="Введите название урока" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="cancel-lesson">Отмена</button>
<button type="button" class="btn btn-primary" id="save-lesson">Сохранить</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById('add-lesson-modal');
document.getElementById('cancel-lesson').addEventListener('click', () => modal.remove());
document.getElementById('save-lesson').addEventListener('click', () => {
const title = document.getElementById('lesson-title').value.trim();
if (!title) { alert('Введите название урока'); return; }
addLesson(moduleIndex, title);
modal.remove();
});
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
}
export function showEditLessonModal(moduleIndex, lessonIndex) {
const lesson = courseStructure.modules[moduleIndex].lessons[lessonIndex];
console.log('showEditLessonModal - lesson:', lesson);
console.log('showEditLessonModal - steps:', lesson?.steps);
const modalHtml = `
<div class="modal-overlay" id="edit-lesson-modal">
<div class="modal" style="max-width: 800px;">
<div class="modal-header"><h3>Редактировать урок: ${lesson.title || 'Без названия'}</h3></div>
<div class="modal-body">
<div class="form-group">
<label for="edit-lesson-title">Название урока *</label>
<input type="text" id="edit-lesson-title" value="${lesson.title || ''}" required>
</div>
<div class="lesson-steps-section">
<h4>Шаги урока</h4>
<p class="section-description">Добавьте шаги с учебным контентом для этого урока</p>
<div class="steps-actions">
<button type="button" class="btn btn-outline" id="add-step-btn"><span class="btn-icon">+</span> Добавить шаг</button>
</div>
<div id="lesson-steps" class="lesson-steps">
${lesson.steps && lesson.steps.length > 0 ?
lesson.steps.map((step, stepIndex) => `
<div class="step-item" data-step-index="${stepIndex}">
<div class="step-header">
<div class="step-title">
<span class="step-number">шаг${stepIndex + 1}</span>
<span class="step-name">"${step.title || 'Без названия'}"</span>
<span class="step-type">(${getStepTypeLabel(step.type)})</span>
</div>
<div class="step-actions">
<button type="button" class="module-action-btn edit-step" data-step-index="${stepIndex}">✏️</button>
<button type="button" class="module-action-btn delete delete-step" data-step-index="${stepIndex}">🗑️</button>
</div>
</div>
</div>
`).join('') :
'<div class="empty-steps"><p>Пока нет шагов. Добавьте первый шаг.</p></div>'
}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="cancel-edit-lesson">Закрыть</button>
<button type="button" class="btn btn-primary" id="save-lesson-title">Сохранить название</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById('edit-lesson-modal');
document.getElementById('cancel-edit-lesson').addEventListener('click', () => modal.remove());
document.getElementById('save-lesson-title').addEventListener('click', () => {
const title = document.getElementById('edit-lesson-title').value.trim();
if (!title) { alert('Введите название урока'); return; }
updateLesson(moduleIndex, lessonIndex, title);
modal.remove();
});
document.getElementById('add-step-btn').addEventListener('click', () => {
import('./StepManager.js').then(m => m.showAddStepModal(moduleIndex, lessonIndex));
});
setTimeout(() => {
document.querySelectorAll('.edit-step').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
import('./StepManager.js').then(m => m.showEditStepModal(moduleIndex, lessonIndex, parseInt(button.dataset.stepIndex)));
});
});
document.querySelectorAll('.delete-step').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const stepIndex = parseInt(button.dataset.stepIndex);
if (confirm('Удалить этот шаг?')) {
import('./StepManager.js').then(m => {
m.deleteStep(moduleIndex, lessonIndex, stepIndex);
modal.remove();
showEditLessonModal(moduleIndex, lessonIndex);
});
}
});
});
}, 100);
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
}
export async function addLesson(moduleIndex, title) {
const module = courseStructure.modules[moduleIndex];
if (!module.lessons) module.lessons = [];
const newLesson = { title, steps: [] };
module.lessons.push(newLesson);
renderCourseStructure();
try {
const lesson = await coursesAPI.createLesson({ module_id: module.id, title, order_index: module.lessons.length - 1 });
if (lesson?.id) newLesson.id = lesson.id;
await saveDraft();
} catch (error) { console.error('Failed to create lesson:', error); }
}
export async function updateLesson(moduleIndex, lessonIndex, title) {
const lesson = courseStructure.modules[moduleIndex].lessons[lessonIndex];
lesson.title = title;
renderCourseStructure();
try {
if (lesson.id) await coursesAPI.updateLesson(lesson.id, { title });
await saveDraft();
} catch (error) { console.error('Failed to update lesson:', error); }
}
export async function deleteLesson(moduleIndex, lessonIndex) {
const lesson = courseStructure.modules[moduleIndex].lessons[lessonIndex];
try {
if (lesson.id) await coursesAPI.deleteLesson(lesson.id);
courseStructure.modules[moduleIndex].lessons.splice(lessonIndex, 1);
renderCourseStructure();
await saveDraft();
} catch (error) { console.error('Failed to delete lesson:', error); showMessage('❌ Ошибка при удалении урока', 'error'); }
}

View File

@@ -0,0 +1,115 @@
import { coursesAPI } from '../api/courses.js';
import { currentCourseId, courseStructure } from './State.js';
import { saveDraft } from './DraftManager.js';
import { renderCourseStructure } from './StructureManager.js';
import { showMessage } from './CourseUI.js';
export function showAddModuleModal() {
const modalHtml = `
<div class="modal-overlay" id="add-module-modal">
<div class="modal">
<div class="modal-header"><h3>Добавить модуль</h3></div>
<div class="modal-body">
<div class="form-group">
<label for="module-title">Название модуля *</label>
<input type="text" id="module-title" placeholder="Введите название модуля" required>
</div>
<div class="form-group">
<label for="module-duration">Длительность (опционально)</label>
<input type="text" id="module-duration" placeholder="Например: 2 недели, 10 часов">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="cancel-module">Отмена</button>
<button type="button" class="btn btn-primary" id="save-module">Сохранить</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById('add-module-modal');
document.getElementById('cancel-module').addEventListener('click', () => modal.remove());
document.getElementById('save-module').addEventListener('click', () => {
const title = document.getElementById('module-title').value.trim();
const duration = document.getElementById('module-duration').value.trim();
if (!title) { alert('Введите название модуля'); return; }
addModule(title, duration);
modal.remove();
});
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
}
export function showEditModuleModal(moduleIndex) {
const module = courseStructure.modules[moduleIndex];
const modalHtml = `
<div class="modal-overlay" id="edit-module-modal">
<div class="modal">
<div class="modal-header"><h3>Редактировать модуль</h3></div>
<div class="modal-body">
<div class="form-group">
<label for="edit-module-title">Название модуля *</label>
<input type="text" id="edit-module-title" value="${module.title || ''}" required>
</div>
<div class="form-group">
<label for="edit-module-duration">Длительность (опционально)</label>
<input type="text" id="edit-module-duration" value="${module.duration || ''}" placeholder="Например: 2 недели, 10 часов">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="cancel-edit-module">Отмена</button>
<button type="button" class="btn btn-primary" id="save-edit-module">Сохранить</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById('edit-module-modal');
document.getElementById('cancel-edit-module').addEventListener('click', () => modal.remove());
document.getElementById('save-edit-module').addEventListener('click', () => {
const title = document.getElementById('edit-module-title').value.trim();
const duration = document.getElementById('edit-module-duration').value.trim();
if (!title) { alert('Введите название модуля'); return; }
updateModule(moduleIndex, title, duration);
modal.remove();
});
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
}
export async function addModule(title, duration) {
const newModule = { title, duration: duration || null, lessons: [] };
courseStructure.modules.push(newModule);
renderCourseStructure();
try {
const module = await coursesAPI.createModule({ course_id: currentCourseId, title, duration: duration || null, order_index: courseStructure.modules.length - 1 });
if (module?.id) newModule.id = module.id;
await saveDraft();
} catch (error) {
console.error('Failed to create module:', error);
showMessage('⚠️ Модуль сохранен только локально', 'warning');
}
}
export async function updateModule(moduleIndex, title, duration) {
const module = courseStructure.modules[moduleIndex];
module.title = title;
module.duration = duration || null;
renderCourseStructure();
try {
if (module.id) await coursesAPI.updateModule(module.id, { title, duration: duration || null });
await saveDraft();
} catch (error) { console.error('Failed to update module:', error); }
}
export async function deleteModule(moduleIndex) {
const module = courseStructure.modules[moduleIndex];
try {
if (module.id) await coursesAPI.deleteModule(module.id);
courseStructure.modules.splice(moduleIndex, 1);
renderCourseStructure();
await saveDraft();
} catch (error) { console.error('Failed to delete module:', error); showMessage('❌ Ошибка при удалении модуля', 'error'); }
}

View File

@@ -0,0 +1,64 @@
let vditorInstance = null;
let globalStepVditorInstance = null;
let currentCourseId = null;
let isEditMode = false;
let isDraftMode = false;
let pendingDraftContent = null;
const courseStructure = { modules: [] };
export function getState() {
return { vditorInstance, globalStepVditorInstance, currentCourseId, isEditMode, isDraftMode, courseStructure };
}
export function getVditorInstance() { return vditorInstance; }
export function setVditorInstance(instance) {
vditorInstance = instance;
if (pendingDraftContent !== null && instance) {
trySetValue(instance, pendingDraftContent);
if (pendingDraftContent === null) {
// Content was set successfully
}
}
}
function trySetValue(instance, content, retries = 5) {
if (!instance) return;
try {
if (instance.vditor && instance.vditor.currentMode && instance.vditor.ir) {
instance.setValue(content);
pendingDraftContent = null;
} else if (retries > 0) {
setTimeout(() => trySetValue(instance, content, retries - 1), 100);
}
} catch (e) {
if (retries > 0) {
setTimeout(() => trySetValue(instance, content, retries - 1), 100);
} else {
console.warn('Failed to set pending content after retries:', e);
}
}
}
export function getGlobalStepVditorInstance() { return globalStepVditorInstance; }
export function setGlobalStepVditorInstance(instance) { globalStepVditorInstance = instance; }
export function getCurrentCourseId() { return currentCourseId; }
export function setCurrentCourseId(id) { currentCourseId = id; }
export function getIsEditMode() { return isEditMode; }
export function setIsEditMode(value) { isEditMode = value; }
export function getIsDraftMode() { return isDraftMode; }
export function setIsDraftMode(value) { isDraftMode = value; }
export function setPendingDraftContent(content) {
if (vditorInstance && vditorInstance.vditor && vditorInstance.vditor.currentMode) {
try {
vditorInstance.setValue(content);
} catch (e) {
pendingDraftContent = content;
}
} else {
pendingDraftContent = content;
}
}
export { courseStructure, vditorInstance, globalStepVditorInstance, currentCourseId, isEditMode, isDraftMode };

View File

@@ -0,0 +1,400 @@
import { courseStructure, globalStepVditorInstance, setGlobalStepVditorInstance } from './State.js';
import { coursesAPI } from '../api/courses.js';
import { showMessage } from './CourseUI.js';
import { getStepTypeLabel } from './Constants.js';
import { getStepVditorConfig } from '../editors/vditorConfig.js';
import { TestEditor } from './TestEditor.js';
export function showAddStepModal(moduleIndex, lessonIndex) {
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
const modalHtml = `
<div class="modal-overlay" id="add-step-modal">
<div class="modal" style="max-width: 700px;">
<div class="modal-header"><h3>Добавить шаг</h3></div>
<div class="modal-body">
<div class="form-group">
<label for="step-type">Тип учебного контента *</label>
<select id="step-type" class="step-type-select">
<option value="theory">Теоретический материал</option>
<option value="presentation">Презентация (PDF)</option>
<option value="practice">Практическое задание</option>
<option value="lab">Лабораторная работа</option>
<option value="test">Тест</option>
</select>
</div>
<div id="step-content-fields">
<div class="theory-content-fields">
<div class="form-group">
<label for="step-title">Заголовок шага (опционально)</label>
<input type="text" id="step-title" placeholder="Введите заголовок шага">
</div>
<div class="form-group">
<label for="step-content">Содержание *</label>
<div id="step-vditor-container"></div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="cancel-step">Отмена</button>
<button type="button" class="btn btn-primary" id="save-step">Сохранить шаг</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById('add-step-modal');
setTimeout(() => {
if (typeof Vditor !== 'undefined') {
const instance = new Vditor('step-vditor-container', getStepVditorConfig());
setGlobalStepVditorInstance(instance);
}
}, 100);
document.getElementById('step-type').addEventListener('change', (e) => updateStepContentFields(e.target.value));
document.getElementById('cancel-step').addEventListener('click', () => {
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
modal.remove();
});
document.getElementById('save-step').addEventListener('click', () => saveNewStep(moduleIndex, lessonIndex, modal));
modal.addEventListener('click', (e) => { if (e.target === modal) { if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); } modal.remove(); }});
}
async function saveNewStep(moduleIndex, lessonIndex, modal) {
const type = document.getElementById('step-type').value;
const title = document.getElementById('step-title')?.value.trim() || '';
const content = type === 'theory' && globalStepVditorInstance ? globalStepVditorInstance.getValue() : '';
const fileInput = document.getElementById('step-pdf-input');
let fileUrl = null;
if (type === 'presentation') {
if (!fileInput || !fileInput.files[0]) {
alert('Загрузите PDF файл для презентации');
return;
}
try {
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const response = await coursesAPI.uploadFile(formData);
fileUrl = response.url;
} catch (error) {
alert('Ошибка загрузки файла: ' + error.message);
return;
}
} else if (type === 'theory' && !content.trim()) {
alert('Введите содержание теоретического материала');
return;
}
await addStep(moduleIndex, lessonIndex, {
type,
title: title || null,
content,
file_url: fileUrl,
order: courseStructure.modules[moduleIndex].lessons[lessonIndex].steps?.length || 0
});
modal.remove();
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
const lessonModal = document.getElementById('edit-lesson-modal');
if (lessonModal) {
lessonModal.remove();
import('./LessonManager.js').then(m => m.showEditLessonModal(moduleIndex, lessonIndex));
}
}
export function showEditStepModal(moduleIndex, lessonIndex, stepIndex) {
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
const step = courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex];
const modalHtml = `
<div class="modal-overlay" id="edit-step-modal">
<div class="modal" style="max-width: 700px;">
<div class="modal-header"><h3>Редактировать шаг: ${getStepTypeLabel(step.type)}</h3></div>
<div class="modal-body">
<div class="form-group">
<label for="edit-step-type">Тип учебного контента *</label>
<select id="edit-step-type" class="step-type-select" disabled>
<option value="theory" ${step.type === 'theory' ? 'selected' : ''}>Теоретический материал</option>
<option value="practice" ${step.type === 'practice' ? 'selected' : ''}>Практическое задание</option>
<option value="lab" ${step.type === 'lab' ? 'selected' : ''}>Лабораторная работа</option>
<option value="test" ${step.type === 'test' ? 'selected' : ''}>Тест</option>
</select>
</div>
<div id="edit-step-content-fields">
<div class="theory-content-fields">
<div class="form-group">
<label for="edit-step-title">Заголовок шага (опционально)</label>
<input type="text" id="edit-step-title" value="${step.title || ''}" placeholder="Введите заголовок шага">
</div>
<div class="form-group">
<label for="edit-step-content">Содержание *</label>
<div id="edit-step-vditor-container"></div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="cancel-edit-step">Отмена</button>
<button type="button" class="btn btn-primary" id="save-edit-step">Сохранить изменения</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById('edit-step-modal');
setTimeout(() => {
if (typeof Vditor !== 'undefined') {
const instance = new Vditor('edit-step-vditor-container', { ...getStepVditorConfig(), value: '' });
setGlobalStepVditorInstance(instance);
setTimeout(() => { if (instance && step.content) instance.setValue(step.content); }, 200);
}
}, 100);
document.getElementById('cancel-edit-step').addEventListener('click', () => {
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
modal.remove();
});
document.getElementById('save-edit-step').addEventListener('click', () => {
const title = document.getElementById('edit-step-title')?.value.trim() || '';
const content = globalStepVditorInstance ? globalStepVditorInstance.getValue() : '';
if (step.type === 'theory' && !content.trim()) { alert('Введите содержание теоретического материала'); return; }
updateStep(moduleIndex, lessonIndex, stepIndex, { ...step, title: title || null, content });
modal.remove();
const lessonModal = document.getElementById('edit-lesson-modal');
if (lessonModal) {
lessonModal.remove();
import('./LessonManager.js').then(m => m.showEditLessonModal(moduleIndex, lessonIndex));
}
});
modal.addEventListener('click', (e) => { if (e.target === modal) { if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); } modal.remove(); }});
}
export function updateStepContentFields(stepType) {
const contentFields = document.getElementById('step-content-fields');
if (stepType === 'theory') {
contentFields.innerHTML = `
<div class="theory-content-fields">
<div class="form-group">
<label for="step-title">Заголовок шага (опционально)</label>
<input type="text" id="step-title" placeholder="Введите заголовок шага">
</div>
<div class="form-group">
<label for="step-content">Содержание *</label>
<div id="step-vditor-container"></div>
</div>
</div>`;
setTimeout(() => { if (typeof Vditor !== 'undefined') new Vditor('step-vditor-container', getStepVditorConfig()); }, 50);
} else if (stepType === 'presentation') {
contentFields.innerHTML = `
<div class="presentation-content-fields">
<div class="form-group">
<label for="step-title">Заголовок шага (опционально)</label>
<input type="text" id="step-title" placeholder="Введите заголовок презентации">
</div>
<div class="form-group">
<label for="step-pdf-input">PDF файл презентации *</label>
<div class="file-upload-area" id="pdf-drop-zone">
<div class="file-upload-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="14,2 14,8 20,8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="12" y1="18" x2="12" y2="12" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="9,15 12,12 15,15" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<p>Перетащите PDF файл сюда или</p>
<input type="file" id="step-pdf-input" accept=".pdf" style="display: none;">
<button type="button" class="btn btn-outline" onclick="document.getElementById('step-pdf-input').click()">Выбрать файл</button>
</div>
<div class="file-preview" id="pdf-file-preview" style="display: none;">
<span class="file-name"></span>
<button type="button" class="btn-icon remove-file" onclick="clearPdfFile()">×</button>
</div>
</div>
<p class="help-text">Поддерживаются файлы в формате PDF. Максимальный размер: 50 МБ.</p>
</div>
</div>`;
initPdfDropZone();
} else if (stepType === 'test') {
contentFields.innerHTML = `
<div class="test-content-fields">
<div class="form-group">
<label for="step-title">Заголовок шага (опционально)</label>
<input type="text" id="step-title" placeholder="Введите заголовок теста">
</div>
<div class="form-group">
<label>Тест будет создан после сохранения шага</label>
<p class="content-note">После создания шага вы сможете добавить вопросы и настроить тест.</p>
</div>
</div>`;
} else {
contentFields.innerHTML = `
<div class="other-content-fields">
<div class="form-group">
<label for="step-title">Заголовок шага (опционально)</label>
<input type="text" id="step-title" placeholder="Введите заголовок шага">
</div>
<div class="form-group">
<label>Тип контента: ${getStepTypeLabel(stepType)}</label>
<p class="content-note">Редактирование этого типа контента будет доступно в будущих версиях.</p>
</div>
</div>`;
}
}
function initPdfDropZone() {
const dropZone = document.getElementById('pdf-drop-zone');
const fileInput = document.getElementById('step-pdf-input');
if (!dropZone || !fileInput) return;
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => dropZone.classList.add('highlight'), false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => dropZone.classList.remove('highlight'), false);
});
dropZone.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].type === 'application/pdf') {
handlePdfFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handlePdfFile(e.target.files[0]);
}
});
}
function handlePdfFile(file) {
if (file.type !== 'application/pdf') {
alert('Пожалуйста, выберите файл в формате PDF');
return;
}
if (file.size > 50 * 1024 * 1024) {
alert('Размер файла не должен превышать 50 МБ');
return;
}
const preview = document.getElementById('pdf-file-preview');
const fileName = preview.querySelector('.file-name');
const uploadContent = document.querySelector('.file-upload-content');
if (preview && fileName && uploadContent) {
fileName.textContent = file.name;
preview.style.display = 'flex';
uploadContent.style.display = 'none';
}
}
window.clearPdfFile = function() {
const fileInput = document.getElementById('step-pdf-input');
const preview = document.getElementById('pdf-file-preview');
const uploadContent = document.querySelector('.file-upload-content');
if (fileInput) fileInput.value = '';
if (preview) preview.style.display = 'none';
if (uploadContent) uploadContent.style.display = 'block';
};
export async function addStep(moduleIndex, lessonIndex, stepData) {
if (!courseStructure.modules[moduleIndex].lessons[lessonIndex].steps) {
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps = [];
}
const lesson = courseStructure.modules[moduleIndex].lessons[lessonIndex];
try {
if (lesson.id) {
const createdStep = await coursesAPI.createStep({
lesson_id: lesson.id,
type: stepData.type,
title: stepData.title,
content: stepData.content,
order: stepData.order
});
const newStep = {
id: createdStep?.id,
type: stepData.type,
title: stepData.title,
content: stepData.content,
order: stepData.order
};
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.push(newStep);
showMessage('✅ Шаг создан', 'success');
} else {
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.push(stepData);
showMessage('⚠️ Шаг сохранён локально (урок не синхронизирован)', 'warning');
}
} catch (error) {
console.error('Failed to create step:', error);
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.push(stepData);
showMessage('❌ Ошибка при создании шага', 'error');
}
}
export async function updateStep(moduleIndex, lessonIndex, stepIndex, stepData) {
const step = courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex];
try {
if (step.id) {
await coursesAPI.updateStep(step.id, {
type: stepData.type,
title: stepData.title,
content: stepData.content,
order: stepData.order
});
showMessage('✅ Шаг обновлён', 'success');
}
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex] = stepData;
} catch (error) {
console.error('Failed to update step:', error);
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex] = stepData;
showMessage('❌ Ошибка при обновлении шага', 'error');
}
}
export async function deleteStep(moduleIndex, lessonIndex, stepIndex) {
const step = courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex];
try {
if (step?.id) {
await coursesAPI.deleteStep(step.id);
showMessage('✅ Шаг удалён', 'success');
}
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.splice(stepIndex, 1);
} catch (error) {
console.error('Failed to delete step:', error);
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.splice(stepIndex, 1);
showMessage('❌ Ошибка при удалении шага', 'error');
}
}

View File

@@ -0,0 +1,128 @@
import { courseStructure } from './State.js';
import { saveDraft } from './DraftManager.js';
import { showMessage } from './CourseUI.js';
import { renderCourseStructure } from './StructureManager.js';
export function addStructureEventListeners() {
// Add lesson buttons
document.querySelectorAll('.add-lesson-btn, .module-action-btn.add-lesson').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const moduleIndex = parseInt(button.dataset.moduleIndex);
import('./LessonManager.js').then(m => m.showAddLessonModal(moduleIndex));
});
});
// Edit module buttons
document.querySelectorAll('.edit-module').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const moduleIndex = parseInt(button.dataset.moduleIndex);
import('./ModuleManager.js').then(m => m.showEditModuleModal(moduleIndex));
});
});
// Delete module buttons
document.querySelectorAll('.delete-module').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const moduleIndex = parseInt(button.dataset.moduleIndex);
if (confirm('Удалить этот модуль и все его уроки?')) {
import('./ModuleManager.js').then(m => m.deleteModule(moduleIndex));
}
});
});
// Edit lesson buttons - navigate to lesson editor
document.querySelectorAll('.edit-lesson').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const moduleIndex = parseInt(button.dataset.moduleIndex);
const lessonIndex = parseInt(button.dataset.lessonIndex);
const lesson = courseStructure.modules[moduleIndex]?.lessons?.[lessonIndex];
if (lesson && lesson.id) {
// Navigate to lesson editor page
window.location.href = `/course-builder/lesson/${lesson.id}?moduleIndex=${moduleIndex}&lessonIndex=${lessonIndex}`;
} else {
showMessage('❌ Сначала сохраните урок', 'error');
}
});
});
// Delete lesson buttons
document.querySelectorAll('.delete-lesson').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const moduleIndex = parseInt(button.dataset.moduleIndex);
const lessonIndex = parseInt(button.dataset.lessonIndex);
if (confirm('Удалить этот урок?')) {
import('./LessonManager.js').then(m => m.deleteLesson(moduleIndex, lessonIndex));
}
});
});
// Module header click to toggle
document.querySelectorAll('.module-header').forEach(header => {
header.addEventListener('click', () => {
const moduleContent = header.nextElementSibling;
moduleContent.style.display = moduleContent.style.display === 'none' ? 'block' : 'none';
});
});
// Drag and drop for lessons
setupDragAndDrop();
}
function setupDragAndDrop() {
document.querySelectorAll('.lesson-item.draggable').forEach(lesson => {
lesson.addEventListener('dragstart', (e) => {
lesson.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ moduleIndex: lesson.dataset.moduleIndex, lessonIndex: lesson.dataset.lessonIndex }));
});
lesson.addEventListener('dragend', () => {
lesson.classList.remove('dragging');
document.querySelectorAll('.lesson-item.drag-over').forEach(el => el.classList.remove('drag-over'));
});
lesson.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
lesson.classList.add('drag-over');
});
lesson.addEventListener('dragleave', () => lesson.classList.remove('drag-over'));
lesson.addEventListener('drop', (e) => {
e.preventDefault();
lesson.classList.remove('drag-over');
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
const sourceModuleIndex = parseInt(data.moduleIndex);
const sourceLessonIndex = parseInt(data.lessonIndex);
const targetModuleIndex = parseInt(lesson.dataset.moduleIndex);
const targetLessonIndex = parseInt(lesson.dataset.lessonIndex);
if (sourceModuleIndex === targetModuleIndex && sourceLessonIndex === targetLessonIndex) return;
moveLesson(sourceModuleIndex, sourceLessonIndex, targetModuleIndex, targetLessonIndex);
} catch (err) { console.error('Error handling drop:', err); }
});
});
}
async function moveLesson(sourceModuleIndex, sourceLessonIndex, targetModuleIndex, targetLessonIndex) {
const sourceModule = courseStructure.modules[sourceModuleIndex];
const targetModule = courseStructure.modules[targetModuleIndex];
if (!sourceModule || !targetModule || !sourceModule.lessons || !targetModule.lessons) return;
const [lesson] = sourceModule.lessons.splice(sourceLessonIndex, 1);
if (!lesson) return;
if (targetLessonIndex === targetModule.lessons.length) targetModule.lessons.push(lesson);
else targetModule.lessons.splice(targetLessonIndex, 0, lesson);
renderCourseStructure();
saveDraft();
showMessage(sourceModule.id === targetModule.id ? '✅ Порядок уроков обновлен' : '✅ Урок перемещён между модулями', 'success');
}

View File

@@ -0,0 +1,136 @@
import { coursesAPI } from '../api/courses.js';
import { currentCourseId, isEditMode, isDraftMode, courseStructure, setCurrentCourseId, setIsEditMode, setIsDraftMode, setPendingDraftContent } from './State.js';
import { showMessage, updateBuilderTitle } from './CourseUI.js';
import { addStructureEventListeners } from './StructureEvents.js';
import { loadLastDraft } from './DraftManager.js';
export async function initCourseStructure() {
const urlParams = new URLSearchParams(window.location.search);
const courseId = urlParams.get('course_id');
if (courseId) {
await loadCourseForEditing(courseId);
renderCourseStructure();
} else {
const lastDraft = await loadLastDraft();
if (lastDraft) {
// renderCourseStructure already called in loadDraftIntoEditor
} else {
try {
const course = await coursesAPI.createCourse({
title: 'Черновик курса', description: '', level: 'beginner', duration: 1,
content: '# Заголовок курса\n\nСоздайте ваш курс здесь...', structure: { modules: courseStructure.modules }
});
setCurrentCourseId(course.id);
setIsDraftMode(true);
showMessage('✅ Создан новый черновик', 'success');
} catch (error) {
console.error('Failed to create initial draft:', error);
loadStructureFromLocalStorage();
}
renderCourseStructure();
}
}
document.getElementById('add-module-btn').addEventListener('click', () => import('./ModuleManager.js').then(m => m.showAddModuleModal()));
}
export async function loadCourseForEditing(courseId) {
try {
const course = await coursesAPI.getCourseStructure(courseId);
if (!course) { showMessage('❌ Курс не найден', 'error'); return; }
setCurrentCourseId(courseId);
setIsEditMode(true);
setIsDraftMode(false);
updateBuilderTitle(true);
document.getElementById('course-title').value = course.title || '';
document.getElementById('course-description').value = course.description || '';
document.getElementById('course-level').value = course.level || 'beginner';
setPendingDraftContent(course.content || '');
if (course.modules) {
courseStructure.modules = course.modules.map(module => ({
id: module.id,
title: module.title,
duration: module.duration,
lessons: module.lessons ? module.lessons.map(lesson => ({
id: lesson.id,
title: lesson.title,
steps: lesson.steps ? lesson.steps.map(step => ({
id: step.id,
type: step.step_type,
title: step.title,
content: step.content,
order: step.order_index
})) : []
})) : []
}));
}
showMessage(`✅ Загружен курс "${course.title}"`, 'success');
} catch (error) { console.error('Failed to load course:', error); showMessage('❌ Не удалось загрузить курс для редактирования', 'error'); }
}
export function renderCourseStructure() {
const container = document.getElementById('course-structure');
if (courseStructure.modules.length === 0) {
container.innerHTML = '<div class="empty-structure"><p>Пока нет модулей. Добавьте первый модуль, чтобы начать.</p></div>';
return;
}
let html = '';
courseStructure.modules.forEach((module, moduleIndex) => {
html += `
<div class="module-item" data-module-index="${moduleIndex}">
<div class="module-header">
<div class="module-title">
<span class="module-icon">📚</span>
<span>${module.title || 'Без названия'}</span>
${module.duration ? `<span class="module-duration">(${module.duration})</span>` : ''}
</div>
<div class="module-actions">
<button type="button" class="module-action-btn add-lesson" data-module-index="${moduleIndex}">+ Урок</button>
<button type="button" class="module-action-btn edit-module" data-module-index="${moduleIndex}">✏️</button>
<button type="button" class="module-action-btn delete delete-module" data-module-index="${moduleIndex}">🗑️</button>
</div>
</div>
<div class="module-content">
<div class="lessons-list">
${module.lessons && module.lessons.length > 0 ?
module.lessons.map((lesson, lessonIndex) => `
<div class="lesson-item draggable" draggable="true" data-module-index="${moduleIndex}" data-lesson-index="${lessonIndex}">
<div class="lesson-info">
<div class="drag-handle" title="Перетащите для изменения порядка">⋮⋮</div>
<div class="lesson-number">${lessonIndex + 1}</div>
<div class="lesson-title">${lesson.title || 'Без названия'}</div>
</div>
<div class="lesson-actions">
<button type="button" class="module-action-btn edit-lesson" data-module-index="${moduleIndex}" data-lesson-index="${lessonIndex}">Редактировать</button>
<button type="button" class="module-action-btn delete delete-lesson" data-module-index="${moduleIndex}" data-lesson-index="${lessonIndex}">🗑️</button>
</div>
</div>
`).join('') :
'<p class="no-lessons">Пока нет уроков в этом модуле.</p>'
}
</div>
<button type="button" class="add-lesson-btn" data-module-index="${moduleIndex}"><span>+</span> Добавить урок</button>
</div>
</div>`;
});
container.innerHTML = html;
addStructureEventListeners();
}
function loadStructureFromLocalStorage() {
const draft = localStorage.getItem('course_draft');
if (draft) {
try {
const data = JSON.parse(draft);
if (data.structure && data.structure.modules) courseStructure.modules = data.structure.modules;
} catch (e) { console.error('Error loading structure from draft:', e); }
}
}

View File

@@ -0,0 +1,462 @@
import { coursesAPI } from '../api/courses.js';
export class TestEditor {
constructor(stepId, testData = null) {
this.stepId = stepId;
this.questions = testData?.questions || [];
this.settings = testData?.settings || {
passing_score: 70,
max_attempts: 0,
time_limit: 0,
show_answers: false
};
this.container = null;
this.currentQuestionIndex = -1;
}
async loadQuestions() {
try {
console.log('[TestEditor] Loading questions for step:', this.stepId);
const response = await coursesAPI.getTestQuestions(this.stepId);
console.log('[TestEditor] API response:', response);
if (response) {
this.questions = response.questions || [];
this.settings = response.settings || this.settings;
console.log('[TestEditor] Loaded questions:', this.questions.length);
console.log('[TestEditor] Settings:', this.settings);
}
} catch (error) {
console.error('[TestEditor] Failed to load questions:', error);
}
}
render(containerElement) {
if (containerElement) {
this.container = containerElement;
}
if (!this.container) {
console.warn('[TestEditor] No container element');
return '';
}
console.log('[TestEditor] Rendering with questions:', this.questions.length);
const html = this.getHTML();
this.container.innerHTML = html;
console.log('[TestEditor] HTML rendered, attaching listeners...');
this.attachEventListeners();
console.log('[TestEditor] Listeners attached');
return this.container.innerHTML;
}
getHTML() {
return `
<div class="test-editor">
<div class="test-settings">
<h4>Настройки теста</h4>
<div class="settings-grid">
<div class="form-group">
<label for="passing-score">Проходной балл (%)</label>
<input type="number" id="passing-score" class="form-control"
value="${this.settings.passing_score}" min="0" max="100">
</div>
<div class="form-group">
<label for="max-attempts">Макс. попыток (0 = без ограничений)</label>
<input type="number" id="max-attempts" class="form-control"
value="${this.settings.max_attempts}" min="0">
</div>
<div class="form-group">
<label for="time-limit">Ограничение по времени (мин, 0 = без ограничений)</label>
<input type="number" id="time-limit" class="form-control"
value="${this.settings.time_limit}" min="0">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="show-answers" ${this.settings.show_answers ? 'checked' : ''}>
Показывать правильные ответы после завершения
</label>
</div>
</div>
<button class="btn btn-primary btn-sm" id="save-settings-btn">
Сохранить настройки
</button>
</div>
<div class="test-questions">
<div class="questions-header">
<h4>Вопросы теста (${this.questions.length})</h4>
<div class="add-question-buttons">
<button class="btn btn-sm btn-outline" data-type="single_choice">✓ Один вариант</button>
<button class="btn btn-sm btn-outline" data-type="multiple_choice">☑ Несколько вариантов</button>
<button class="btn btn-sm btn-outline" data-type="text_answer">✎ Текстовый ответ</button>
<button class="btn btn-sm btn-outline" data-type="match">⇄ Соответствие</button>
</div>
</div>
<div class="questions-list" id="questions-list">
${this.renderQuestionsList()}
</div>
</div>
<div class="question-editor-panel" id="question-editor-panel" style="display: ${this.currentQuestionIndex >= 0 ? 'block' : 'none'};">
${this.currentQuestionIndex >= 0 ? this.renderQuestionEditor() : '<p>Выберите вопрос для редактирования</p>'}
</div>
</div>
`;
}
attachEventListeners() {
if (!this.container) {
console.warn('[TestEditor] No container in attachEventListeners');
return;
}
console.log('[TestEditor] Attaching event listeners...');
// Save settings button
const saveBtn = this.container.querySelector('#save-settings-btn');
if (saveBtn) {
console.log('[TestEditor] Found save settings button');
saveBtn.addEventListener('click', () => {
console.log('[TestEditor] Save settings clicked');
this.saveSettings();
});
}
// Add question buttons
const addButtons = this.container.querySelectorAll('.add-question-buttons button');
console.log('[TestEditor] Found', addButtons.length, 'add question buttons');
addButtons.forEach(btn => {
btn.addEventListener('click', () => {
console.log('[TestEditor] Add question clicked:', btn.dataset.type);
this.addQuestion(btn.dataset.type);
});
});
// Question items
this.container.querySelectorAll('.question-item').forEach((item, index) => {
item.addEventListener('click', (e) => {
if (!e.target.classList.contains('delete-question')) {
this.selectQuestion(index);
}
});
item.querySelector('.delete-question')?.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteQuestion(index);
});
});
// Question editor buttons
if (this.currentQuestionIndex >= 0) {
this.container.querySelector('#save-question-btn')?.addEventListener('click', () => this.saveCurrentQuestion());
this.container.querySelector('#add-answer-btn')?.addEventListener('click', () => this.addAnswer());
this.container.querySelector('#add-keyword-btn')?.addEventListener('click', () => this.addKeyword());
this.container.querySelector('#add-match-btn')?.addEventListener('click', () => this.addMatchItem());
// Answer delete buttons
this.container.querySelectorAll('.remove-answer').forEach((btn, idx) => {
btn.addEventListener('click', () => this.removeAnswer(idx));
});
this.container.querySelectorAll('.remove-keyword').forEach((btn, idx) => {
btn.addEventListener('click', () => this.removeKeyword(idx));
});
this.container.querySelectorAll('.remove-match').forEach((btn, idx) => {
btn.addEventListener('click', () => this.removeMatchItem(idx));
});
}
}
renderQuestionsList() {
if (this.questions.length === 0) {
return '<p class="no-questions">Нет вопросов. Нажмите кнопку выше, чтобы добавить первый вопрос.</p>';
}
return this.questions.map((q, index) => `
<div class="question-item ${index === this.currentQuestionIndex ? 'active' : ''}" data-index="${index}">
<span class="question-number">${index + 1}</span>
<span class="question-type-badge ${q.question_type}">${this.getQuestionTypeLabel(q.question_type)}</span>
<span class="question-text">${(q.question_text || 'Новый вопрос').substring(0, 50)}${(q.question_text || '').length > 50 ? '...' : ''}</span>
<span class="question-points">${q.points || 1} балл(ов)</span>
<button class="btn-icon delete-question" title="Удалить">×</button>
</div>
`).join('');
}
renderQuestionEditor() {
const question = this.questions[this.currentQuestionIndex];
if (!question) return '';
return `
<div class="editor-header">
<h4>Редактирование вопроса ${this.currentQuestionIndex + 1}</h4>
<button class="btn btn-sm btn-primary" id="save-question-btn">Сохранить вопрос</button>
</div>
<div class="form-group">
<label>Текст вопроса</label>
<textarea id="question-text" class="form-control" rows="3">${question.question_text || ''}</textarea>
</div>
<div class="form-group">
<label>Баллы за правильный ответ</label>
<input type="number" id="question-points" class="form-control" value="${question.points || 1}" min="1">
</div>
${this.renderQuestionTypeEditor(question)}
`;
}
renderQuestionTypeEditor(question) {
switch (question.question_type) {
case 'single_choice':
return this.renderSingleChoiceEditor(question);
case 'multiple_choice':
return this.renderMultipleChoiceEditor(question);
case 'text_answer':
return this.renderTextAnswerEditor(question);
case 'match':
return this.renderMatchEditor(question);
default:
return '';
}
}
renderSingleChoiceEditor(question) {
return `
<div class="answers-editor">
<label>Варианты ответов (отметьте правильный)</label>
<div class="answers-list">
${(question.answers || []).map((a, idx) => `
<div class="answer-item" data-index="${idx}">
<input type="radio" name="correct-answer" value="${idx}" ${a.is_correct ? 'checked' : ''}>
<input type="text" class="form-control answer-text" value="${a.answer_text || ''}">
<button class="btn-icon remove-answer">×</button>
</div>
`).join('')}
</div>
<button class="btn btn-sm btn-outline" id="add-answer-btn">+ Добавить вариант</button>
</div>
`;
}
renderMultipleChoiceEditor(question) {
return `
<div class="answers-editor">
<label>Варианты ответов (отметьте правильные)</label>
<div class="answers-list">
${(question.answers || []).map((a, idx) => `
<div class="answer-item" data-index="${idx}">
<input type="checkbox" class="correct-answer" ${a.is_correct ? 'checked' : ''}>
<input type="text" class="form-control answer-text" value="${a.answer_text || ''}">
<button class="btn-icon remove-answer">×</button>
</div>
`).join('')}
</div>
<button class="btn btn-sm btn-outline" id="add-answer-btn">+ Добавить вариант</button>
</div>
`;
}
renderTextAnswerEditor(question) {
return `
<div class="text-answer-editor">
<label>Ключевые слова для проверки ответа</label>
<p class="help-text">Введите ключевые слова или фразы, которые должны содержаться в ответе студента</p>
<div class="keywords-list">
${(question.correct_answers || []).map((answer, idx) => `
<div class="keyword-item" data-index="${idx}">
<input type="text" class="form-control keyword-text" value="${answer}">
<button class="btn-icon remove-keyword">×</button>
</div>
`).join('')}
</div>
<button class="btn btn-sm btn-outline" id="add-keyword-btn">+ Добавить ключевое слово</button>
</div>
`;
}
renderMatchEditor(question) {
return `
<div class="match-editor">
<label>Пары для сопоставления</label>
<div class="match-list">
${(question.match_items || []).map((item, idx) => `
<div class="match-item" data-index="${idx}">
<input type="text" class="form-control match-left" placeholder="Левая часть" value="${item.left_text || ''}">
<span class="match-arrow">→</span>
<input type="text" class="form-control match-right" placeholder="Правая часть" value="${item.right_text || ''}">
<button class="btn-icon remove-match">×</button>
</div>
`).join('')}
</div>
<button class="btn btn-sm btn-outline" id="add-match-btn">+ Добавить пару</button>
</div>
`;
}
getQuestionTypeLabel(type) {
const labels = {
single_choice: 'Один вариант',
multiple_choice: 'Несколько',
text_answer: 'Текст',
match: 'Соответствие'
};
return labels[type] || type;
}
async saveSettings() {
const settings = {
passing_score: parseInt(document.getElementById('passing-score').value) || 70,
max_attempts: parseInt(document.getElementById('max-attempts').value) || 0,
time_limit: parseInt(document.getElementById('time-limit').value) || 0,
show_answers: document.getElementById('show-answers').checked
};
try {
await coursesAPI.updateTestSettings(this.stepId, settings);
this.settings = settings;
alert('Настройки сохранены');
} catch (error) {
alert('Ошибка сохранения настроек: ' + error.message);
}
}
addQuestion(type) {
const question = {
question_text: '',
question_type: type,
points: 1,
order_index: this.questions.length,
answers: type === 'single_choice' || type === 'multiple_choice' ? [
{ answer_text: '', is_correct: false, order_index: 0 },
{ answer_text: '', is_correct: false, order_index: 1 }
] : [],
match_items: type === 'match' ? [
{ left_text: '', right_text: '', order_index: 0 }
] : [],
correct_answers: type === 'text_answer' ? [] : []
};
this.questions.push(question);
this.currentQuestionIndex = this.questions.length - 1;
this.render();
}
selectQuestion(index) {
this.currentQuestionIndex = index;
this.render();
}
async deleteQuestion(index) {
if (!confirm('Удалить этот вопрос?')) return;
const question = this.questions[index];
if (question.id) {
try {
await coursesAPI.deleteTestQuestion(this.stepId, question.id);
} catch (error) {
alert('Ошибка удаления вопроса: ' + error.message);
return;
}
}
this.questions.splice(index, 1);
if (this.currentQuestionIndex >= this.questions.length) {
this.currentQuestionIndex = this.questions.length - 1;
}
this.render();
}
addAnswer() {
const question = this.questions[this.currentQuestionIndex];
if (!question.answers) question.answers = [];
question.answers.push({ answer_text: '', is_correct: false, order_index: question.answers.length });
this.render();
}
removeAnswer(index) {
const question = this.questions[this.currentQuestionIndex];
question.answers.splice(index, 1);
this.render();
}
addKeyword() {
const question = this.questions[this.currentQuestionIndex];
if (!question.correct_answers) question.correct_answers = [];
question.correct_answers.push('');
this.render();
}
removeKeyword(index) {
const question = this.questions[this.currentQuestionIndex];
question.correct_answers.splice(index, 1);
this.render();
}
addMatchItem() {
const question = this.questions[this.currentQuestionIndex];
if (!question.match_items) question.match_items = [];
question.match_items.push({ left_text: '', right_text: '', order_index: question.match_items.length });
this.render();
}
removeMatchItem(index) {
const question = this.questions[this.currentQuestionIndex];
question.match_items.splice(index, 1);
this.render();
}
async saveCurrentQuestion() {
const question = this.questions[this.currentQuestionIndex];
if (!question) return;
question.question_text = document.getElementById('question-text').value;
question.points = parseInt(document.getElementById('question-points').value) || 1;
// Collect answers based on question type
if (question.question_type === 'single_choice') {
const correctRadio = this.container.querySelector('input[name="correct-answer"]:checked');
const correctIndex = correctRadio ? parseInt(correctRadio.value) : -1;
question.answers = Array.from(this.container.querySelectorAll('.answer-item')).map((item, idx) => ({
answer_text: item.querySelector('.answer-text').value,
is_correct: idx === correctIndex,
order_index: idx
}));
} else if (question.question_type === 'multiple_choice') {
question.answers = Array.from(this.container.querySelectorAll('.answer-item')).map((item, idx) => ({
answer_text: item.querySelector('.answer-text').value,
is_correct: item.querySelector('.correct-answer').checked,
order_index: idx
}));
} else if (question.question_type === 'text_answer') {
question.correct_answers = Array.from(this.container.querySelectorAll('.keyword-text')).map(input => input.value);
} else if (question.question_type === 'match') {
question.match_items = Array.from(this.container.querySelectorAll('.match-item')).map((item, idx) => ({
left_text: item.querySelector('.match-left').value,
right_text: item.querySelector('.match-right').value,
order_index: idx
}));
}
try {
if (question.id) {
await coursesAPI.updateTestQuestion(this.stepId, question.id, question);
} else {
const created = await coursesAPI.createTestQuestion(this.stepId, question);
if (created) question.id = created.id;
}
alert('Вопрос сохранен');
this.render();
} catch (error) {
alert('Ошибка сохранения вопроса: ' + error.message);
}
}
}

View File

@@ -0,0 +1,9 @@
// Course Builder - Main export file
export { courseStructure, currentCourseId, isEditMode, isDraftMode } from './State.js';
export { saveDraft, previewCourse, publishCourse, validateCourseStructure, updateDraftsCount, showDraftsModal, loadDraftIntoEditor, loadLastDraft } from './DraftManager.js';
export { initCourseStructure, loadCourseForEditing, renderCourseStructure } from './StructureManager.js';
export { showAddModuleModal, showEditModuleModal, addModule, updateModule, deleteModule } from './ModuleManager.js';
export { showAddLessonModal, showEditLessonModal, addLesson, updateLesson, deleteLesson } from './LessonManager.js';
export { showAddStepModal, showEditStepModal, addStep, updateStep, deleteStep } from './StepManager.js';
export { showMessage, updateBuilderTitle } from './CourseUI.js';
export { getStepTypeLabel, validationRules } from './Constants.js';

View File

@@ -0,0 +1,69 @@
export const tooltipMap = {
'Emoji': 'Эмодзи', 'Headings': 'Заголовки', 'Bold': 'Жирный', 'Italic': 'Курсив',
'Strike': 'Зачеркнутый', 'Link': 'Ссылка', 'Unordered List': 'Маркированный список',
'Ordered List': 'Нумерованный список', 'Checklist': 'Список задач', 'Outdent': 'Уменьшить отступ',
'Indent': 'Увеличить отступ', 'Quote': 'Цитата', 'Line': 'Разделитель', 'Code Block': 'Блок кода',
'Inline Code': 'Встроенный код', 'Insert After': 'Вставить строку после', 'Insert Before': 'Вставить строку перед',
'Upload': 'Загрузить', 'Table': 'Таблица', 'Undo': 'Отменить', 'Redo': 'Повторить',
'Edit Mode': 'Режим редактирования', 'Both': 'Редактирование и просмотр', 'Preview': 'Только просмотр',
'Fullscreen': 'Полный экран', 'Outline': 'Оглавление', 'Code Theme': 'Тема кода',
'Content Theme': 'Тема контента', 'Export': 'Экспорт', 'Help': 'Помощь', 'More': 'Еще',
'Close': 'Закрыть', 'Confirm': 'Подтвердить', 'Cancel': 'Отмена', 'Insert': 'Вставить',
'Delete': 'Удалить', 'Edit': 'Редактировать', 'Save': 'Сохранить', 'Loading': 'Загрузка',
'Success': 'Успешно', 'Error': 'Ошибка', 'Warning': 'Предупреждение', 'Info': 'Информация'
};
export const getVditorConfig = (initialContent = '') => ({
height: 500,
placeholder: 'Начните писать содержание курса здесь...',
theme: 'classic',
icon: 'material',
typewriterMode: true,
lang: 'ru_RU',
toolbar: ['emoji', 'headings', 'bold', 'italic', 'strike', 'link', '|', 'list', 'ordered-list',
'check', 'outdent', 'indent', '|', 'quote', 'line', 'code', 'inline-code', 'insert-before',
'insert-after', '|', 'upload', 'table', '|', 'undo', 'redo', '|', 'edit-mode', 'both',
'preview', 'fullscreen', 'outline', 'code-theme', 'content-theme', 'export', 'help'],
toolbarConfig: { pin: true },
cache: { enable: false },
preview: {
delay: 500,
hljs: { enable: true, style: 'github', lineNumber: true },
math: { engine: 'KaTeX', inlineDigit: true },
markdown: { toc: true, mark: true, footnotes: true, autoSpace: true }
},
hint: {
emojiPath: 'https://unpkg.com/vditor@3.10.4/dist/images/emoji',
emojiTail: '<a href="https://ld246.com/settings/function" target="_blank">Настроить эмодзи</a>',
emoji: { '+1': '👍', '-1': '👎', 'heart': '❤️', 'smile': '😊', 'fire': '🔥', 'star': '⭐' }
},
upload: {
accept: 'image/*,.pdf,.doc,.docx,.txt',
handler(files) {
console.log('Files to upload:', files);
return Promise.resolve(['https://example.com/upload/demo.jpg']);
},
filename(name) {
return name.replace(/[^(a-zA-Z0-9\u4e00-\u9fa5\.)]/g, '')
.replace(/[\?\\/:|<>\*\[\]\(\)\$%\{\}@~]/g, '').replace('/\\s/g', '');
},
msg: 'Перетащите файлы сюда или нажмите для выбора',
error(msg) { alert('Ошибка загрузки: ' + msg); },
success(fileName) { console.log('Файл загружен:', fileName); }
},
value: initialContent
});
export const getStepVditorConfig = (initialContent = '') => ({
height: 300,
placeholder: 'Введите теоретический материал здесь...',
theme: 'classic',
icon: 'material',
lang: 'ru_RU',
toolbar: ['emoji', 'headings', 'bold', 'italic', 'strike', 'link', '|', 'list', 'ordered-list',
'check', 'outdent', 'indent', '|', 'quote', 'line', 'code', 'inline-code', '|', 'upload',
'table', '|', 'undo', 'redo', '|', 'edit-mode', 'both', 'preview', 'fullscreen', 'outline',
'code-theme', 'content-theme', 'export', 'help'],
preview: { hljs: { enable: true, style: 'github', lineNumber: true }, math: { engine: 'KaTeX', inlineDigit: true } },
value: initialContent
});

View File

@@ -0,0 +1,51 @@
import { getVditorConfig, getStepVditorConfig } from './vditorConfig.js';
import { applyRussianLabels, addRussianUILabels, monitorAndUpdateRussianContent } from './vditorLocalization.js';
import { setVditorInstance, setGlobalStepVditorInstance, setPendingDraftContent } from '../course-builder/State.js';
let vditorInstance = null;
let globalStepVditorInstance = null;
export function loadVditor() {
return new Promise((resolve, reject) => {
if (typeof Vditor !== 'undefined') { resolve(); return; }
const script = document.createElement('script');
script.src = 'https://unpkg.com/vditor@3.10.4/dist/index.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
export function initVditorEditor(containerId, initialContent = '') {
const container = document.getElementById(containerId);
if (!container) { console.error(`Container #${containerId} not found`); return null; }
const config = getVditorConfig(initialContent);
config.after = () => {
console.log('Vditor initialized with Russian locale');
vditorInstance = window.vditor || vditorInstance;
setVditorInstance(vditorInstance);
setTimeout(() => {
applyRussianLabels();
addRussianUILabels();
monitorAndUpdateRussianContent();
}, 300);
};
vditorInstance = new Vditor(containerId, config);
return vditorInstance;
}
export function initStepVditorEditor(containerId, initialContent = '') {
const container = document.getElementById(containerId);
if (!container) { console.error(`Container #${containerId} not found`); return null; }
const config = getStepVditorConfig(initialContent);
config.after = () => {
setGlobalStepVditorInstance(globalStepVditorInstance);
};
globalStepVditorInstance = new Vditor(containerId, config);
return globalStepVditorInstance;
}
export { vditorInstance, globalStepVditorInstance };

View File

@@ -0,0 +1,95 @@
import { tooltipMap } from './vditorConfig.js';
export function applyRussianLabels() {
setTimeout(() => {
const toolbar = document.querySelector('.vditor-toolbar');
if (!toolbar) return;
toolbar.querySelectorAll('[aria-label]').forEach(button => {
const originalLabel = button.getAttribute('aria-label');
if (tooltipMap[originalLabel]) {
button.setAttribute('aria-label', tooltipMap[originalLabel]);
button.setAttribute('title', tooltipMap[originalLabel]);
}
});
const modeButtons = toolbar.querySelectorAll('.vditor-toolbar__item[data-type="edit-mode"]');
modeButtons.forEach(button => {
const span = button.querySelector('span');
if (span) {
span.textContent = span.textContent.replace('Edit', 'Редакт.')
.replace('Preview', 'Просмотр').replace('Both', 'Оба');
}
});
updateDropdownMenus();
}, 100);
}
export function updateDropdownMenus() {
const menus = [
{ selector: '.vditor-menu--headings', map: { 'Heading': 'Заголовок', 'Paragraph': 'Параграф' } },
{ selector: '.vditor-menu--code-theme', map: { 'Light': 'Светлая', 'Dark': 'Темная' } },
{ selector: '.vditor-menu--content-theme', map: { 'Light': 'Светлая', 'Dark': 'Темная' } },
{ selector: '.vditor-menu--export', map: { 'HTML': 'HTML', 'PDF': 'PDF', 'Markdown': 'Markdown' } }
];
menus.forEach(({ selector, map }) => {
const menu = document.querySelector(selector);
if (menu) {
menu.querySelectorAll('.vditor-menu__item').forEach(item => {
Object.entries(map).forEach(([en, ru]) => {
if (item.textContent.includes(en)) item.textContent = item.textContent.replace(en, ru);
});
});
}
});
}
export function addRussianUILabels() {
setTimeout(() => {
const dialogs = [
{ selector: '.vditor-dialog--link', labelMap: { 'Text': 'Текст ссылки:', 'Link': 'URL ссылки:', 'Title': 'Заголовок (опционально):' }, btnMap: { 'Insert': 'Вставить', 'Cancel': 'Отмена' } },
{ selector: '.vditor-dialog--table', labelMap: { 'Rows': 'Строки:', 'Columns': 'Столбцы:' }, btnMap: { 'Insert': 'Вставить', 'Cancel': 'Отмена' } },
{ selector: '.vditor-dialog--upload', btnMap: { 'Upload': 'Загрузить', 'Cancel': 'Отмена', 'Browse': 'Выбрать файл' } }
];
dialogs.forEach(({ selector, labelMap = {}, btnMap = {} }) => {
const dialog = document.querySelector(selector);
if (dialog) {
dialog.querySelectorAll('label').forEach(label => {
Object.entries(labelMap).forEach(([en, ru]) => {
if (label.textContent.includes(en)) label.textContent = ru;
});
});
dialog.querySelectorAll('button').forEach(button => {
Object.entries(btnMap).forEach(([en, ru]) => {
if (button.textContent.includes(en)) button.textContent = ru;
});
});
}
});
const helpDialog = document.querySelector('.vditor-dialog--help');
if (helpDialog) {
const title = helpDialog.querySelector('.vditor-dialog__title');
if (title && title.textContent.includes('Help')) title.textContent = 'Помощь по Markdown';
}
updateContextMenu();
}, 200);
}
export function updateContextMenu() {
const contextMenu = document.querySelector('.vditor-contextmenu');
if (!contextMenu) return;
const map = { 'Cut': 'Вырезать', 'Copy': 'Копировать', 'Paste': 'Вставить', 'Select All': 'Выделить все', 'Delete': 'Удалить' };
contextMenu.querySelectorAll('.vditor-contextmenu__item').forEach(item => {
Object.entries(map).forEach(([en, ru]) => {
if (item.textContent.includes(en)) item.textContent = ru;
});
});
}
export function monitorAndUpdateRussianContent() {
setInterval(() => { updateDropdownMenus(); updateContextMenu(); applyRussianLabels(); }, 1000);
}

22
frontend/src/main.js Normal file
View File

@@ -0,0 +1,22 @@
import { router } from './router.js';
import { initAuth } from './components/auth.js';
import { renderNav } from './components/nav.js';
import { API_BASE_URL } from './utils/config.js';
document.addEventListener('DOMContentLoaded', async () => {
console.log('App starting...');
// Render navigation first (with default state for non-authenticated user)
renderNav();
// Initialize authentication (will update navigation if user is authenticated)
await initAuth();
// Initialize router
router.init();
// Handle initial route
router.handleRoute();
console.log('App started');
});

83
frontend/src/router.js Normal file
View File

@@ -0,0 +1,83 @@
export const router = {
routes: {
'/': 'home',
'/courses': 'courses',
'/courses/:id': 'courseDetail',
'/courses/:id/view': 'courseViewer',
'/course-builder': 'courseBuilder',
'/course-builder/lesson/:lessonId': 'lessonEditor',
'/favorites': 'favorites',
'/progress': 'progress',
'/login': 'login',
'/dev-tools': 'devTools'
},
init() {
window.addEventListener('popstate', () => this.handleRoute());
document.addEventListener('click', (e) => {
if (e.target.matches('[data-link]')) {
e.preventDefault();
const href = e.target.getAttribute('href');
console.log('Data-link clicked:', href);
this.navigate(href);
}
});
},
navigate(path) {
console.log('Navigating to:', path);
history.pushState(null, null, path);
this.handleRoute();
},
handleRoute() {
const path = window.location.pathname;
console.log('Handling route:', path);
const app = document.getElementById('app');
// Parse route params
let matchedRoute = null;
let params = {};
for (const route in this.routes) {
const routePattern = route.replace(/:\w+/g, '([^/]+)');
const regex = new RegExp(`^${routePattern}$`);
const match = path.match(regex);
if (match) {
matchedRoute = route;
const paramNames = [...route.matchAll(/:(\w+)/g)].map(match => match[1]);
paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
break;
}
}
if (!matchedRoute) {
console.log('No route matched, showing 404');
app.innerHTML = '<h1>404 - Page Not Found</h1>';
return;
}
const viewName = this.routes[matchedRoute];
console.log('Loading view:', viewName, 'with params:', params);
this.loadView(viewName, params, app);
},
async loadView(viewName, params, container) {
try {
console.log(`Importing view module: ./views/${viewName}.js`);
const module = await import(`./views/${viewName}.js`);
console.log(`Rendering view: ${viewName}`);
container.innerHTML = await module.render(params);
if (module.afterRender) {
console.log(`Calling afterRender for: ${viewName}`);
module.afterRender(params);
}
} catch (error) {
console.error(`Failed to load view ${viewName}:`, error);
container.innerHTML = '<h1>Error loading page</h1>';
}
}
};

View File

@@ -0,0 +1,558 @@
.course-viewer {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
background: #f5f7fa;
}
.viewer-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e1e5eb;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-left .back-link {
color: #666;
text-decoration: none;
font-size: 14px;
}
.header-left .back-link:hover {
color: #333;
}
.header-left .course-title {
font-size: 20px;
font-weight: 600;
color: #1a1a2e;
margin: 0;
}
.header-right {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.header-spacer {
display: block;
}
.step-tabs {
display: flex;
gap: 4px;
}
.step-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: #f0f2f5;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.step-tab:hover {
background: #e4e6eb;
}
.step-tab.active {
background: #2563eb;
color: #fff;
}
.step-tab .tab-icon {
font-size: 14px;
}
.viewer-body {
display: flex;
flex: 1;
overflow: hidden;
}
.structure-sidebar {
width: 280px;
background: #fff;
border-right: 1px solid #e1e5eb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #e1e5eb;
}
.sidebar-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.structure-tree {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.tree-module {
margin-bottom: 4px;
}
.tree-module .module-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
cursor: pointer;
transition: background 0.2s;
}
.tree-module .module-header:hover {
background: #f5f7fa;
}
.tree-module.active > .module-header {
background: #e8f0fe;
color: #1a73e8;
}
.module-icon {
font-size: 14px;
}
.module-title {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.module-duration {
font-size: 11px;
color: #888;
background: #f0f2f5;
padding: 2px 6px;
border-radius: 4px;
}
.module-lessons {
padding-left: 24px;
}
.tree-lesson {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.tree-lesson:hover {
background: #f5f7fa;
}
.tree-lesson.active {
background: #e8f0fe;
border-left-color: #1a73e8;
color: #1a73e8;
}
.lesson-icon {
font-size: 12px;
}
.lesson-title {
font-size: 13px;
}
.no-lessons {
padding: 8px 16px;
font-size: 12px;
color: #999;
}
.content-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.step-content {
flex: 1;
padding: 32px 48px;
overflow-y: auto;
background: #fff;
margin: 16px;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.step-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e1e5eb;
}
.step-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #666;
}
.breadcrumb-sep {
color: #ccc;
}
.step-type-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.step-type-badge.step-type-theory {
background: #e3f2fd;
color: #1565c0;
}
.step-type-badge.step-type-practice {
background: #fff3e0;
color: #ef6c00;
}
.step-type-badge.step-type-lab {
background: #f3e5f5;
color: #7b1fa2;
}
.step-type-badge.step-type-test {
background: #e8f5e9;
color: #2e7d32;
}
.step-type-badge.step-type-presentation {
background: #e8eaf6;
color: #3949ab;
}
.step-icon {
font-size: 14px;
}
.step-title {
font-size: 24px;
font-weight: 600;
color: #1a1a2e;
margin: 0 0 24px 0;
}
.step-body {
font-size: 15px;
line-height: 1.7;
color: #333;
}
.step-body h1 {
font-size: 28px;
margin: 24px 0 16px;
}
.step-body h2 {
font-size: 22px;
margin: 20px 0 12px;
}
.step-body h3 {
font-size: 18px;
margin: 16px 0 10px;
}
.step-body p {
margin: 0 0 16px;
}
.step-body ul, .step-body ol {
margin: 0 0 16px;
padding-left: 24px;
}
.step-body li {
margin-bottom: 8px;
}
.step-body code {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 13px;
}
.step-body pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
}
.step-body pre code {
background: none;
padding: 0;
color: inherit;
}
.step-body blockquote {
border-left: 4px solid #2563eb;
margin: 16px 0;
padding: 8px 16px;
background: #f8fafc;
color: #555;
}
.step-body table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.step-body th, .step-body td {
border: 1px solid #e1e5eb;
padding: 10px 12px;
text-align: left;
}
.step-body th {
background: #f5f7fa;
font-weight: 600;
}
.step-body img {
max-width: 100%;
border-radius: 8px;
margin: 16px 0;
}
.step-body a {
color: #2563eb;
text-decoration: none;
}
.step-body a:hover {
text-decoration: underline;
}
.step-navigation-bottom {
padding: 16px 48px;
background: #fff;
border-top: 1px solid #e1e5eb;
margin: 0 16px 16px;
border-radius: 0 0 12px 12px;
}
.nav-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.nav-btn:not(:disabled) {
background: #2563eb;
color: #fff;
}
.nav-btn:not(:disabled):hover {
background: #1d4ed8;
}
.nav-btn:disabled {
background: #e1e5eb;
color: #999;
cursor: not-allowed;
}
.step-counter {
font-size: 14px;
color: #666;
}
.no-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
}
.no-content h2 {
margin-bottom: 8px;
}
.loading-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: #666;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e1e5eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-content {
padding: 32px;
text-align: center;
color: #dc2626;
}
.error-page {
padding: 48px;
text-align: center;
}
.error-page h1 {
color: #dc2626;
margin-bottom: 16px;
}
.start-learning-btn {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
font-size: 16px;
font-weight: 500;
text-decoration: none;
border-radius: 8px;
}
@media (max-width: 768px) {
.viewer-body {
flex-direction: column;
}
.structure-sidebar {
width: 100%;
max-height: 200px;
}
.step-content {
padding: 16px;
margin: 8px;
}
.step-navigation-bottom {
padding: 12px 16px;
margin: 0 8px 8px;
}
.nav-btn {
padding: 8px 12px;
font-size: 12px;
}
}
.step-type-presentation {
padding: 0 !important;
margin: 0 !important;
background: #fff;
border-radius: 12px;
overflow: hidden;
border: 1px solid #e0e0e0;
}
.step-type-presentation .step-header {
padding: 16px 24px;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
}
.step-type-presentation .step-body {
padding: 0;
min-height: 500px;
}
.pdf-viewer-container {
width: 100%;
min-height: 500px;
background: #fff;
}
.course-viewer.presentation-fullscreen {
height: 100vh;
}
.course-viewer.presentation-fullscreen .content-area {
width: 100%;
}
.course-viewer.presentation-fullscreen .step-content {
height: calc(100vh - 70px);
}
.fullscreen-btn {
display: flex;
align-items: center;
gap: 6px;
background: #2563eb;
color: #fff;
}
.fullscreen-btn:hover {
background: #1d4ed8;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,630 @@
.lesson-editor-page {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
background: #f8fafc;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e1e5eb;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.back-link {
color: #666;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
color: #333;
}
.lesson-title-input input {
font-size: 18px;
font-weight: 600;
border: none;
border-bottom: 2px solid transparent;
padding: 8px 12px;
background: transparent;
transition: border-color 0.2s;
}
.lesson-title-input input:focus {
outline: none;
border-bottom-color: #6366f1;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.save-status {
font-size: 13px;
color: #10b981;
}
.save-status.dirty {
color: #f59e0b;
}
.lesson-editor-page .editor-body {
display: grid;
grid-template-columns: 20% 80%;
flex: 1;
overflow: hidden;
}
.steps-sidebar {
background: #fff;
border-right: 1px solid #e1e5eb;
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e1e5eb;
}
.sidebar-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.steps-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.step-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
margin-bottom: 4px;
background: #f8fafc;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.step-item:hover {
background: #e8f0fe;
}
.step-item.active {
background: #e8f0fe;
border-left: 3px solid #6366f1;
}
.step-item.dragging {
opacity: 0.5;
}
.step-drag-handle {
color: #ccc;
cursor: grab;
font-size: 12px;
}
.step-drag-handle:active {
cursor: grabbing;
}
.step-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.step-icon {
font-size: 16px;
}
.step-number {
font-size: 12px;
font-weight: 600;
color: #6366f1;
}
.step-title {
font-size: 13px;
color: #333;
}
.step-actions {
opacity: 0;
transition: opacity 0.2s;
}
.step-item:hover .step-actions {
opacity: 1;
}
.btn-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: #666;
font-size: 18px;
}
.btn-icon:hover {
background: #fee2e2;
color: #ef4444;
}
.empty-steps {
text-align: center;
padding: 40px 16px;
color: #999;
}
.loading-steps {
text-align: center;
padding: 40px 16px;
color: #666;
}
.editor-main {
background: #fff;
border-right: 1px solid #e1e5eb;
overflow-y: auto;
}
.step-editor {
padding: 24px;
}
.editor-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 16px;
}
.step-editor-content {
max-width: 100%;
margin: 0;
padding: 0 32px;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-control:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
select.form-control {
cursor: pointer;
}
textarea.form-control {
resize: vertical;
min-height: 200px;
}
#vditor-container {
border: 1px solid #d1d5db;
border-radius: 6px;
overflow: hidden;
}
.file-upload-area {
border: 2px dashed #d1d5db;
border-radius: 8px;
padding: 40px;
text-align: center;
transition: all 0.2s;
cursor: pointer;
}
.file-upload-area:hover,
.file-upload-area.dragover {
border-color: #6366f1;
background: #f8fafc;
}
.upload-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.upload-prompt svg {
color: #999;
}
.upload-prompt p {
color: #666;
margin: 0;
}
.file-preview {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.file-name {
font-size: 14px;
color: #333;
}
.pdf-preview-note {
padding: 16px;
background: #f8fafc;
border-radius: 8px;
color: #666;
}
.pdf-preview-container {
text-align: center;
padding: 40px;
}
.pdf-preview-container .file-icon {
font-size: 48px;
display: block;
margin-bottom: 16px;
}
.pdf-preview-container .file-name {
font-size: 16px;
font-weight: 500;
color: #333;
}
.pdf-preview-hint {
margin-top: 12px;
font-size: 14px;
color: #999;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
width: 90%;
max-width: 900px;
animation: slideUp 0.3s;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e1e5eb;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
font-size: 24px;
color: #999;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.modal-close:hover {
background: #f3f4f6;
color: #333;
}
.modal-body {
padding: 24px;
overflow-y: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #e1e5eb;
background: #f8fafc;
}
.preview-step {
background: #fff;
}
.preview-step-header {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #e1e5eb;
}
.preview-step-type {
display: inline-block;
padding: 6px 12px;
background: #e8f0fe;
color: #6366f1;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
}
.preview-step-header h4 {
margin: 0;
font-size: 20px;
color: #1a1a2e;
}
.preview-step-body {
line-height: 1.7;
color: #333;
}
.preview-step-body h1,
.preview-step-body h2,
.preview-step-body h3,
.preview-step-body h4,
.preview-step-body h5,
.preview-step-body h6 {
margin: 24px 0 12px;
color: #1a1a2e;
}
.preview-step-body h1 { font-size: 28px; }
.preview-step-body h2 { font-size: 24px; }
.preview-step-body h3 { font-size: 20px; }
.preview-step-body h4 { font-size: 18px; }
.preview-step-body p {
margin: 12px 0;
}
.preview-step-body ul,
.preview-step-body ol {
margin: 12px 0;
padding-left: 28px;
}
.preview-step-body li {
margin: 6px 0;
}
.preview-step-body code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 0.9em;
}
.preview-step-body pre {
background: #1e1e2e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
}
.preview-step-body pre code {
background: none;
padding: 0;
}
.preview-step-body blockquote {
border-left: 4px solid #6366f1;
margin: 16px 0;
padding: 12px 20px;
background: #f8fafc;
color: #555;
}
.preview-step-body table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.preview-step-body th,
.preview-step-body td {
border: 1px solid #e1e5eb;
padding: 12px;
text-align: left;
}
.preview-step-body th {
background: #f8fafc;
font-weight: 600;
}
.preview-step-body a {
color: #6366f1;
text-decoration: none;
}
.preview-step-body a:hover {
text-decoration: underline;
}
.preview-step-body img {
max-width: 100%;
border-radius: 8px;
margin: 16px 0;
}
.markdown-content {
line-height: 1.7;
}
.preview-step-body h1,
.preview-step-body h2,
.preview-step-body h3 {
margin: 16px 0 8px;
}
.preview-step-body p {
margin: 8px 0;
}
.preview-step-body ul,
.preview-step-body ol {
margin: 8px 0;
padding-left: 24px;
}
.preview-step-body code {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 13px;
}
.preview-step-body pre {
background: #1e1e2e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
@media (max-width: 1200px) {
.editor-body {
grid-template-columns: 240px 1fr;
}
}
@media (max-width: 768px) {
.editor-body {
grid-template-columns: 1fr;
}
.steps-sidebar {
position: fixed;
left: -280px;
top: 60px;
width: 280px;
height: calc(100vh - 60px);
z-index: 1000;
transition: left 0.3s;
}
.steps-sidebar.open {
left: 0;
}
.lesson-title-input {
display: none;
}
.header-right {
flex-wrap: wrap;
gap: 8px;
}
#preview-btn {
order: -1;
}
}

View File

@@ -0,0 +1,188 @@
.pdf-viewer {
background: #fff;
border-radius: 12px;
overflow: hidden;
position: relative;
border: 1px solid #e0e0e0;
}
.pdf-viewer.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
border-radius: 0;
}
.pdf-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
color: #666;
}
.pdf-spinner {
width: 50px;
height: 50px;
border: 3px solid #e0e0e0;
border-top-color: #6366f1;
border-radius: 50%;
animation: pdf-spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes pdf-spin {
to { transform: rotate(360deg); }
}
.pdf-content {
display: flex;
flex-direction: column;
height: 100%;
}
.pdf-controls {
display: flex !important;
flex-direction: row !important;
align-items: center;
justify-content: center;
gap: 24px;
padding: 16px;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
flex-wrap: nowrap;
}
.pdf-nav, .pdf-zoom {
display: flex;
align-items: center;
gap: 12px;
}
.pdf-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
color: #333;
cursor: pointer;
transition: all 0.2s;
}
.pdf-btn:hover {
background: #6366f1;
border-color: #6366f1;
color: #fff;
}
.pdf-btn:active {
transform: scale(0.95);
}
.pdf-page-info {
font-size: 15px;
font-weight: 500;
color: #333;
min-width: 80px;
text-align: center;
}
.pdf-zoom-level {
font-size: 13px;
color: #666;
min-width: 50px;
text-align: center;
}
.pdf-fullscreen-btn {
margin-left: auto;
}
.pdf-progress-bar {
width: 100%;
height: 4px;
background: #e0e0e0;
}
.pdf-progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
width: 0%;
transition: width 0.3s ease;
}
.pdf-canvas-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
overflow: auto;
background: #f9f9f9;
}
#pdf-canvas {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
.pdf-error {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
.pdf-error-content {
text-align: center;
color: #ef4444;
}
.pdf-error-content p {
margin-top: 16px;
font-size: 15px;
}
/* Presentation mode styles */
.step-type-presentation .step-body {
padding: 0;
}
.step-type-presentation .pdf-viewer {
border-radius: 0;
border: none;
}
.presentation-container {
width: 100%;
min-height: 500px;
}
/* Responsive */
@media (max-width: 768px) {
.pdf-controls {
gap: 12px;
padding: 12px;
}
.pdf-btn {
width: 36px;
height: 36px;
}
.pdf-page-info {
font-size: 14px;
}
.pdf-canvas-container {
padding: 12px;
}
}

View File

@@ -0,0 +1,265 @@
/* Test Editor Styles */
.test-editor {
padding: 20px;
}
.test-settings {
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.test-settings h4 {
margin-top: 0;
margin-bottom: 16px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
}
.test-questions {
margin-bottom: 24px;
}
.questions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.questions-header h4 {
margin: 0;
}
.add-question-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.questions-list {
border: 1px solid #e0e0e0;
border-radius: 8px;
max-height: 400px;
overflow-y: auto;
}
.no-questions {
padding: 24px;
text-align: center;
color: #666;
}
.question-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
cursor: pointer;
transition: background 0.2s;
}
.question-item:last-child {
border-bottom: none;
}
.question-item:hover {
background: #f5f5f5;
}
.question-item.active {
background: #e3f2fd;
border-left: 3px solid #2196f3;
}
.question-number {
font-weight: 600;
color: #333;
min-width: 24px;
}
.question-type-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.question-type-badge.single_choice {
background: #e3f2fd;
color: #1976d2;
}
.question-type-badge.multiple_choice {
background: #fff3e0;
color: #f57c00;
}
.question-type-badge.text_answer {
background: #f3e5f5;
color: #7b1fa2;
}
.question-type-badge.match {
background: #e8f5e9;
color: #388e3c;
}
.question-text {
flex: 1;
color: #333;
font-size: 14px;
}
.question-points {
font-size: 12px;
color: #666;
}
.btn-icon {
background: none;
border: none;
color: #999;
font-size: 20px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
border-radius: 4px;
transition: all 0.2s;
}
.btn-icon:hover {
background: #ffebee;
color: #f44336;
}
/* Question Editor Panel */
.question-editor-panel {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-top: 24px;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #e0e0e0;
}
.editor-header h4 {
margin: 0;
}
/* Answer Editor Styles */
.answers-editor,
.text-answer-editor,
.match-editor {
margin-top: 16px;
}
.answers-editor label,
.text-answer-editor label,
.match-editor label {
font-weight: 500;
margin-bottom: 12px;
display: block;
}
.help-text {
font-size: 12px;
color: #666;
margin-bottom: 12px;
}
.answers-list,
.keywords-list,
.match-list {
margin-bottom: 12px;
}
.answer-item,
.keyword-item,
.match-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.answer-item input[type="radio"],
.answer-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.answer-text,
.keyword-text,
.match-left,
.match-right {
flex: 1;
}
.match-arrow {
color: #666;
font-size: 18px;
font-weight: bold;
}
/* Responsive */
@media (max-width: 768px) {
.questions-header {
flex-direction: column;
align-items: stretch;
}
.add-question-buttons {
justify-content: center;
}
.question-item {
flex-wrap: wrap;
}
.question-text {
width: 100%;
order: 5;
margin-top: 8px;
}
.answer-item,
.keyword-item,
.match-item {
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,306 @@
/* Test Viewer Styles */
.test-viewer {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.test-header {
margin-bottom: 24px;
}
.test-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.question-counter {
font-size: 14px;
color: #666;
}
.test-timer {
font-size: 18px;
font-weight: 600;
color: #333;
font-family: monospace;
}
.test-timer.warning {
color: #f44336;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.test-progress {
margin-bottom: 24px;
}
.progress-bar {
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #2196f3, #21cbf3);
transition: width 0.3s ease;
}
.question-container {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
min-height: 300px;
}
.question {
max-width: 100%;
}
.question-header {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.question-points {
font-size: 12px;
color: #666;
background: #f5f5f5;
padding: 4px 12px;
border-radius: 12px;
}
.question-text {
font-size: 18px;
font-weight: 500;
color: #333;
margin-bottom: 24px;
line-height: 1.6;
}
.question-answers {
margin-top: 20px;
}
/* Answer Options */
.answers-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.answer-option {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.answer-option:hover {
border-color: #2196f3;
background: #f5f9ff;
}
.answer-option.selected {
border-color: #2196f3;
background: #e3f2fd;
}
.answer-option input[type="radio"],
.answer-option input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.answer-text {
flex: 1;
font-size: 15px;
color: #333;
}
/* Text Answer */
.text-answer textarea {
width: 100%;
padding: 12px;
font-size: 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
resize: vertical;
transition: border-color 0.2s;
}
.text-answer textarea:focus {
outline: none;
border-color: #2196f3;
}
/* Match Question */
.match-question {
margin-top: 16px;
}
.match-columns {
display: flex;
gap: 24px;
}
.match-column {
flex: 1;
}
.match-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 12px;
}
.match-item span {
flex: 1;
font-size: 14px;
}
.match-item select {
flex: 1;
padding: 8px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
background: #fff;
}
.match-item select:focus {
outline: none;
border-color: #2196f3;
}
/* Navigation */
.test-navigation {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.question-dots {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.dot {
width: 32px;
height: 32px;
border: 2px solid #e0e0e0;
border-radius: 50%;
background: #fff;
font-size: 12px;
font-weight: 600;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.dot:hover {
border-color: #2196f3;
}
.dot.active {
background: #2196f3;
border-color: #2196f3;
color: #fff;
}
.dot.answered {
background: #4caf50;
border-color: #4caf50;
color: #fff;
}
/* Results */
.test-results {
max-width: 500px;
margin: 0 auto;
padding: 40px 20px;
text-align: center;
}
.results-header {
margin-bottom: 32px;
}
.results-header.passed h2 {
color: #4caf50;
}
.results-header.failed h2 {
color: #f44336;
}
.score-circle {
width: 150px;
height: 150px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 24px auto;
font-size: 32px;
font-weight: 700;
}
.results-header.passed .score-circle {
background: #e8f5e9;
color: #4caf50;
border: 4px solid #4caf50;
}
.results-header.failed .score-circle {
background: #ffebee;
color: #f44336;
border: 4px solid #f44336;
}
.results-details {
margin-bottom: 24px;
}
.results-details p {
font-size: 15px;
color: #666;
margin-bottom: 8px;
}
.attempts-info {
font-size: 14px;
color: #999;
margin-bottom: 24px;
}
/* Error State */
.test-error {
padding: 40px;
text-align: center;
color: #f44336;
}

93
frontend/src/utils/api.js Normal file
View File

@@ -0,0 +1,93 @@
export const API_BASE_URL = window.location.origin;
export async function apiRequest(endpoint, options = {}) {
const url = endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`;
const token = getAuthToken();
console.log(`apiRequest to ${url}, token exists: ${!!token}`);
// Don't set Content-Type for FormData - browser will set it automatically with boundary
const isFormData = options.body instanceof FormData;
const defaultHeaders = {
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers
};
// Only set Content-Type if not FormData
if (!isFormData && !defaultHeaders['Content-Type']) {
defaultHeaders['Content-Type'] = 'application/json';
}
let response = await fetch(url, {
...options,
headers: defaultHeaders
});
// Handle 401 Unauthorized - try refresh token first
if (response.status === 401 && !options._isRetry) {
// Consume response body to avoid locking the stream
await response.text();
console.log('Received 401, attempting token refresh...');
const { refreshAccessToken } = await import('../components/auth.js');
const refreshSuccess = await refreshAccessToken();
if (refreshSuccess) {
// Retry request with new token
console.log('Refresh successful, retrying request...');
const newToken = getAuthToken();
const retryHeaders = {
...defaultHeaders,
'Authorization': `Bearer ${newToken}`
};
return apiRequest(endpoint, {
...options,
headers: retryHeaders,
_isRetry: true // Prevent infinite retry loop
});
} else {
// Refresh failed, logout
console.log('Refresh failed, removing auth token');
removeAuthToken();
localStorage.removeItem('refresh_token');
if (window.location.pathname !== '/login') {
console.log('Redirecting to login page');
window.location.href = '/login';
}
return;
}
}
if (!response.ok) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
error.status = response.status;
const text = await response.text();
try {
error.data = JSON.parse(text);
} catch {
error.data = text;
}
throw error;
}
// Check if response has content
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
}
return await response.text();
}
export function getAuthToken() {
return localStorage.getItem('access_token');
}
export function setAuthToken(token) {
localStorage.setItem('access_token', token);
}
export function removeAuthToken() {
localStorage.removeItem('access_token');
}

View File

@@ -0,0 +1,2 @@
export const API_BASE_URL = window.location.origin;
export const APP_NAME = 'Auth Learning App';

View File

@@ -0,0 +1,143 @@
import { currentUser } from '../components/auth.js';
// Import editors
import { loadVditor, initVditorEditor } from '../editors/vditorLoader.js';
// Import course-builder modules
import { courseStructure, currentCourseId, isEditMode, isDraftMode, setVditorInstance, getVditorInstance } from '../course-builder/State.js';
import { saveDraft, previewCourse, publishCourse, updateDraftsCount, showDraftsModal, loadDraftIntoEditor } from '../course-builder/DraftManager.js';
import { initCourseStructure, loadCourseForEditing, renderCourseStructure } from '../course-builder/StructureManager.js';
import { showMessage, updateBuilderTitle } from '../course-builder/CourseUI.js';
export { courseStructure, currentCourseId, isEditMode, isDraftMode };
export async function render() {
if (!currentUser) {
return `
<div class="auth-required">
<h2>Требуется авторизация</h2>
<p>Для доступа к конструктору курса необходимо войти в систему.</p>
<a href="/login" data-link>Войти</a>
</div>`;
}
return `
<div class="course-builder-page">
<div class="course-builder-container">
<h2>Конструктор курса</h2>
<p class="subtitle">Создайте новый курс с помощью редактора Markdown с поддержкой LaTeX</p>
<div class="course-form">
<div class="form-group">
<label for="course-title">Название курса</label>
<input type="text" id="course-title" placeholder="Введите название курса" required>
</div>
<div class="form-group">
<label for="course-description">Краткое описание</label>
<textarea id="course-description" placeholder="Краткое описание курса" rows="3"></textarea>
</div>
<div class="form-group">
<label for="course-level">Уровень сложности</label>
<select id="course-level">
<option value="beginner">Начальный</option>
<option value="intermediate">Средний</option>
<option value="advanced">Продвинутый</option>
</select>
</div>
<div class="course-structure-section">
<h3>Структура курса</h3>
<p class="section-description">Создайте модули и уроки для вашего курса</p>
<div class="structure-actions">
<button type="button" id="add-module-btn" class="btn btn-outline"><span class="btn-icon">+</span> Добавить модуль</button>
</div>
<div id="course-structure" class="course-structure">
<div class="empty-structure"><p>Пока нет модулей. Добавьте первый модуль, чтобы начать.</p></div>
</div>
</div>
<div class="editor-section">
<h3>Содержание курса (Vditor редактор)</h3>
<div id="vditor-container"></div>
<div class="editor-help">
<h4>Возможности редактора:</h4>
<ul>
<li>Редактирование Markdown в реальном времени</li>
<li>Поддержка LaTeX формул через KaTeX</li>
<li>Встроенный предпросмотр</li>
<li>Подсветка синтаксиса кода</li>
<li>Экспорт в HTML и PDF</li>
</ul>
</div>
</div>
<div class="form-actions">
<button type="button" id="manage-drafts" class="btn btn-outline">📝 Мои черновики <span id="drafts-count" class="badge">0</span></button>
<button type="button" id="save-draft" class="btn btn-secondary">Сохранить черновик</button>
<button type="button" id="preview-course" class="btn btn-outline">Предпросмотр</button>
<button type="button" id="publish-course" class="btn btn-primary">Опубликовать курс</button>
</div>
<div id="builder-message" class="message"></div>
</div>
</div>
</div>`;
}
export function afterRender() {
if (!currentUser) return;
initVditor();
initCourseStructure();
document.getElementById('save-draft').addEventListener('click', saveDraft);
document.getElementById('preview-course').addEventListener('click', previewCourse);
document.getElementById('publish-course').addEventListener('click', publishCourse);
document.getElementById('manage-drafts').addEventListener('click', showDraftsModal);
updateDraftsCount();
setTimeout(() => {
const vditor = getVditorInstance();
if (vditor) {
let debounceTimeout;
vditor.vditor.element.addEventListener('keyup', () => {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => { if (currentCourseId) saveDraft(); }, 2000);
});
}
}, 2000);
}
function initVditor() {
const initialContent = getInitialContent();
if (typeof Vditor === 'undefined') loadVditor().then(() => { const inst = initVditorEditor('vditor-container', initialContent); if (inst) setVditorInstance(inst); });
else { const inst = initVditorEditor('vditor-container', initialContent); if (inst) setVditorInstance(inst); }
}
function getInitialContent() {
const draft = localStorage.getItem('course_draft');
let initialContent = '# Заголовок курса\n\n## Введение\nНапишите введение к вашему курсу здесь...\n\n## Основная часть\n- Пункт 1\n- Пункт 2\n\n## Формулы LaTeX\nВведите формулы в формате LaTeX:\n$$E = mc^2$$\n$$\\int_{a}^{b} f(x) dx$$\n\n## Заключение\nПодведите итоги курса...';
if (draft) {
try {
const data = JSON.parse(draft);
if (data.content) initialContent = data.content;
} catch (e) { console.error('Error loading draft for editor:', e); }
}
return initialContent;
}
window.addEventListener('load', () => {
const draft = localStorage.getItem('course_draft');
if (draft) {
try {
const data = JSON.parse(draft);
const titleEl = document.getElementById('course-title');
const descEl = document.getElementById('course-description');
const levelEl = document.getElementById('course-level');
const messageEl = document.getElementById('builder-message');
if (titleEl) titleEl.value = data.title || '';
if (descEl) descEl.value = data.description || '';
if (levelEl) levelEl.value = data.level || 'beginner';
if (messageEl && data.savedAt) {
messageEl.textContent = `📝 Загружен черновик от ${new Date(data.savedAt).toLocaleString()}`;
messageEl.className = 'message info';
}
} catch (e) { console.error('Error loading draft:', e); }
}
});

View File

@@ -0,0 +1,153 @@
import { coursesAPI } from '../api/courses.js';
import { currentUser } from '../components/auth.js';
import { router } from '../router.js';
export async function render(params) {
const courseId = parseInt(params.id);
const course = await coursesAPI.getById(courseId);
if (!course) {
return `<h1>Course not found</h1>`;
}
let isFavorite = false;
let progress = null;
let isAuthor = false;
if (currentUser) {
const favorites = await coursesAPI.getFavorites();
isFavorite = favorites.some(fav => fav.course_id === courseId);
progress = await coursesAPI.getProgress(courseId);
isAuthor = course.author_id === currentUser.id;
}
return `
<div class="course-detail-page">
<a href="/courses" class="back-link" data-link>← Back to Courses</a>
<div class="course-header">
<h1>${course.title}</h1>
<div class="course-meta">
<span class="course-level ${course.level}">${course.level}</span>
<span class="course-duration">${course.duration} hours</span>
<span class="course-date">Added ${new Date(course.created_at).toLocaleDateString()}</span>
<span class="course-status ${course.status}">${course.status}</span>
</div>
<div class="course-actions">
${course.status === 'published' ? `
<a href="/courses/${courseId}/view" data-link class="btn btn-primary start-learning-btn">
🎓 Начать обучение
</a>
` : ''}
${isAuthor ? `
<a href="/course-builder?course_id=${courseId}" data-link class="btn btn-outline edit-course-btn">
✏️ Редактировать курс
</a>
` : ''}
</div>
</div>
<div class="course-content">
<div class="course-description">
<h3>Description</h3>
<p>${course.description}</p>
</div>
${currentUser ? `
<div class="course-actions-panel">
<div class="action-group">
<h3>Favorites</h3>
<button id="toggle-favorite" class="btn ${isFavorite ? 'btn-secondary' : 'btn-primary'}">
${isFavorite ? '♥ Remove from Favorites' : '♡ Add to Favorites'}
</button>
</div>
<div class="action-group">
<h3>Progress Tracking</h3>
${progress ? `
<div class="progress-display">
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress.percentage || (progress.completed_lessons / progress.total_lessons * 100)}%"></div>
</div>
<p>${progress.completed_lessons} of ${progress.total_lessons} lessons completed</p>
<button id="update-progress" class="btn btn-outline">Update Progress</button>
</div>
` : `
<p>You haven't started this course yet.</p>
<button id="start-course" class="btn btn-primary">Start Learning</button>
`}
</div>
</div>
` : `
<div class="auth-prompt">
<p><a href="/login" data-link>Login</a> to add this course to favorites and track your progress.</p>
</div>
`}
</div>
</div>
`;
}
export async function afterRender(params) {
if (!currentUser) return;
const courseId = parseInt(params.id);
// Toggle favorite
const favoriteBtn = document.getElementById('toggle-favorite');
if (favoriteBtn) {
favoriteBtn.addEventListener('click', async () => {
try {
if (favoriteBtn.textContent.includes('Add to')) {
await coursesAPI.addFavorite(courseId);
favoriteBtn.textContent = '♥ Remove from Favorites';
favoriteBtn.classList.remove('btn-primary');
favoriteBtn.classList.add('btn-secondary');
} else {
await coursesAPI.removeFavorite(courseId);
favoriteBtn.textContent = '♡ Add to Favorites';
favoriteBtn.classList.remove('btn-secondary');
favoriteBtn.classList.add('btn-primary');
}
} catch (error) {
alert('Failed to update favorites: ' + error.message);
}
});
}
// Start course
const startBtn = document.getElementById('start-course');
if (startBtn) {
startBtn.addEventListener('click', async () => {
const totalLessons = prompt('Enter total number of lessons for this course:');
if (totalLessons) {
try {
await coursesAPI.updateProgress(courseId, 0, parseInt(totalLessons));
alert('Course started! You can now track your progress.');
location.reload();
} catch (error) {
alert('Failed to start course: ' + error.message);
}
}
});
}
// Update progress
const updateBtn = document.getElementById('update-progress');
if (updateBtn) {
updateBtn.addEventListener('click', async () => {
const completed = prompt('Enter number of completed lessons:');
const total = prompt('Enter total number of lessons:');
if (completed && total) {
try {
await coursesAPI.updateProgress(courseId, parseInt(completed), parseInt(total));
alert('Progress updated successfully!');
location.reload();
} catch (error) {
alert('Failed to update progress: ' + error.message);
}
}
});
}
}

View File

@@ -0,0 +1,451 @@
import { coursesAPI } from '../api/courses.js';
import { PdfViewer } from '../components/PdfViewer.js';
import { TestViewer } from '../components/TestViewer.js';
let currentCourse = null;
let currentModuleIndex = 0;
let currentLessonIndex = 0;
let currentStepIndex = 0;
let loadedSteps = new Map();
let pdfViewerInstance = null;
let testViewerInstance = null;
const stepTypeLabels = {
theory: 'Теория',
practice: 'Практика',
lab: 'Лабораторная',
test: 'Тест',
presentation: 'Презентация'
};
const stepTypeIcons = {
theory: '📖',
practice: '✍️',
lab: '🔬',
test: '📝',
presentation: '📊'
};
export async function render(params) {
const courseId = parseInt(params.id);
try {
currentCourse = await coursesAPI.getCourseStructure(courseId);
} catch (error) {
return `<div class="error-page"><h1>Ошибка загрузки курса</h1><p>${error.message}</p><a href="/courses" data-link>← К курсам</a></div>`;
}
if (!currentCourse || !currentCourse.modules || currentCourse.modules.length === 0) {
return `<div class="error-page"><h1>Курс пуст</h1><p>В курсе нет модулей.</p><a href="/courses/${courseId}" data-link>← К описанию курса</a></div>`;
}
loadedSteps.clear();
return `
<div class="course-viewer">
<div class="viewer-header">
<div class="header-left">
<a href="/courses/${courseId}" data-link class="back-link">← К описанию</a>
<h1 class="course-title">${currentCourse.title}</h1>
</div>
<div class="header-right">
<div class="step-navigation" id="step-nav"></div>
</div>
</div>
<div class="viewer-body">
<aside class="structure-sidebar">
<div class="sidebar-header">
<h3>Содержание</h3>
</div>
<div class="structure-tree" id="structure-tree">
${renderStructureTree()}
</div>
</aside>
<main class="content-area">
<div class="step-content" id="step-content">
<div class="loading-placeholder">
<div class="spinner"></div>
<p>Загрузка контента...</p>
</div>
</div>
<div class="step-navigation-bottom" id="step-nav-bottom"></div>
</main>
</div>
</div>
`;
}
function renderStructureTree() {
if (!currentCourse.modules) return '<p>Нет модулей</p>';
return currentCourse.modules.map((module, mIdx) => `
<div class="tree-module ${mIdx === currentModuleIndex ? 'active' : ''}" data-module="${mIdx}">
<div class="module-header" onclick="window.selectModule(${mIdx})">
<span class="module-icon">📁</span>
<span class="module-title">${module.title}</span>
${module.duration ? `<span class="module-duration">${module.duration}</span>` : ''}
</div>
<div class="module-lessons">
${module.lessons && module.lessons.length > 0 ? module.lessons.map((lesson, lIdx) => `
<div class="tree-lesson ${mIdx === currentModuleIndex && lIdx === currentLessonIndex ? 'active' : ''}"
data-module="${mIdx}" data-lesson="${lIdx}"
onclick="window.selectLesson(${mIdx}, ${lIdx})">
<span class="lesson-icon">📄</span>
<span class="lesson-title">${lesson.title}</span>
</div>
`).join('') : '<div class="no-lessons">Нет уроков</div>'}
</div>
</div>
`).join('');
}
function getCurrentStep() {
const module = currentCourse.modules[currentModuleIndex];
if (!module || !module.lessons) return null;
const lesson = module.lessons[currentLessonIndex];
if (!lesson || !lesson.steps) return null;
return lesson.steps[currentStepIndex] || null;
}
async function loadStepContent(stepId) {
if (loadedSteps.has(stepId)) {
return loadedSteps.get(stepId);
}
const step = await coursesAPI.getStepContent(stepId);
if (step) {
loadedSteps.set(stepId, step);
}
return step;
}
function renderStepContent(step, module, lesson, isLoading = false) {
if (isLoading) {
return `
<div class="loading-placeholder">
<div class="spinner"></div>
<p>Загрузка контента...</p>
</div>
`;
}
if (!step) {
return `<div class="no-content"><p>Шаг не найден</p></div>`;
}
const stepTypeClass = `step-type-${step.step_type}`;
return `
<div class="step-header">
<div class="step-breadcrumb">
<span class="breadcrumb-module">${module.title}</span>
<span class="breadcrumb-sep"></span>
<span class="breadcrumb-lesson">${lesson.title}</span>
</div>
<div class="step-type-badge ${stepTypeClass}">
<span class="step-icon">${stepTypeIcons[step.step_type] || '📄'}</span>
<span class="step-type-label">${stepTypeLabels[step.step_type] || step.step_type}</span>
</div>
</div>
${step.title ? `<h2 class="step-title">${step.title}</h2>` : ''}
<div class="step-body ${step.step_type === 'presentation' ? 'step-type-presentation' : ''}">
${renderStepBodyContent(step)}
</div>
`;
}
function renderStepBodyContent(step) {
if (step.step_type === 'presentation' && step.file_url) {
return `<div id="pdf-container" data-pdf-url="${step.file_url}"></div>`;
}
if (step.step_type === 'test') {
return `<div id="test-container"></div>`;
}
return step.content_html ? step.content_html : `<div class="markdown-content">${step.content || '<p>Нет содержимого</p>'}</div>`;
}
function renderNoContent(message) {
return `<div class="no-content"><p>${message}</p></div>`;
}
function renderStepNavigation() {
const module = currentCourse.modules[currentModuleIndex];
if (!module || !module.lessons) return;
const lesson = module.lessons[currentLessonIndex];
if (!lesson || !lesson.steps || lesson.steps.length === 0) return;
const steps = lesson.steps;
const stepNav = document.getElementById('step-nav');
const stepNavBottom = document.getElementById('step-nav-bottom');
const navHtml = `
<div class="step-tabs">
${steps.map((step, idx) => `
<button class="step-tab ${idx === currentStepIndex ? 'active' : ''}"
onclick="window.selectStep(${idx})"
title="${step.title || stepTypeLabels[step.step_type]}">
<span class="tab-icon">${stepTypeIcons[step.step_type]}</span>
<span class="tab-label">${idx + 1}</span>
</button>
`).join('')}
</div>
`;
if (stepNav) stepNav.innerHTML = navHtml;
const bottomNavHtml = `
<div class="nav-buttons">
<button class="nav-btn prev-btn" onclick="window.navigateStep(-1)" ${!canNavigate(-1) ? 'disabled' : ''}>
← Предыдущий шаг
</button>
<span class="step-counter">Шаг ${currentStepIndex + 1} из ${steps.length}</span>
<button class="nav-btn next-btn" onclick="window.navigateStep(1)" ${!canNavigate(1) ? 'disabled' : ''}>
Следующий шаг →
</button>
${getCurrentStep()?.step_type === 'presentation' ? `
<button class="nav-btn fullscreen-btn" onclick="window.togglePresentationFullscreen()" title="Полноэкранный режим">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
На весь экран
</button>
` : ''}
</div>
`;
if (stepNavBottom) stepNavBottom.innerHTML = bottomNavHtml;
}
function canNavigate(direction) {
const module = currentCourse.modules[currentModuleIndex];
if (!module || !module.lessons) return false;
const lesson = module.lessons[currentLessonIndex];
if (!lesson || !lesson.steps) return false;
const newStepIndex = currentStepIndex + direction;
return newStepIndex >= 0 && newStepIndex < lesson.steps.length;
}
async function updateContent() {
const stepContent = document.getElementById('step-content');
const structureTree = document.getElementById('structure-tree');
if (structureTree) {
structureTree.innerHTML = renderStructureTree();
}
renderStepNavigation();
const module = currentCourse.modules[currentModuleIndex];
if (!module || !module.lessons || module.lessons.length === 0) {
if (stepContent) stepContent.innerHTML = renderNoContent('В этом модуле нет уроков');
return;
}
const lesson = module.lessons[currentLessonIndex];
if (!lesson) {
if (stepContent) stepContent.innerHTML = renderNoContent('Урок не найден');
return;
}
if (!lesson.steps || lesson.steps.length === 0) {
if (stepContent) stepContent.innerHTML = `<div class="no-content"><h2>${lesson.title}</h2><p>В этом уроке нет шагов</p></div>`;
return;
}
const step = lesson.steps[currentStepIndex];
if (!step) {
currentStepIndex = 0;
updateContent();
return;
}
if (stepContent) {
stepContent.innerHTML = renderStepContent(null, module, lesson, true);
try {
const fullStep = await loadStepContent(step.id);
stepContent.innerHTML = renderStepContent(fullStep, module, lesson);
renderMath(stepContent);
initPdfViewer();
initTestViewer();
} catch (error) {
console.error('Failed to load step content:', error);
stepContent.innerHTML = `<div class="error-content"><p>Ошибка загрузки контента: ${error.message}</p></div>`;
}
}
const contentArea = document.querySelector('.content-area');
if (contentArea) {
contentArea.scrollTop = 0;
}
}
window.selectModule = function(mIdx) {
if (mIdx >= 0 && mIdx < currentCourse.modules.length) {
currentModuleIndex = mIdx;
currentLessonIndex = 0;
currentStepIndex = 0;
updateContent();
}
};
window.selectLesson = function(mIdx, lIdx) {
const module = currentCourse.modules[mIdx];
if (module && module.lessons && lIdx >= 0 && lIdx < module.lessons.length) {
currentModuleIndex = mIdx;
currentLessonIndex = lIdx;
currentStepIndex = 0;
updateContent();
}
};
window.selectStep = function(sIdx) {
const module = currentCourse.modules[currentModuleIndex];
if (module && module.lessons) {
const lesson = module.lessons[currentLessonIndex];
if (lesson && lesson.steps && sIdx >= 0 && sIdx < lesson.steps.length) {
currentStepIndex = sIdx;
updateContent();
}
}
};
window.navigateStep = function(direction) {
const module = currentCourse.modules[currentModuleIndex];
if (!module || !module.lessons) return;
const lesson = module.lessons[currentLessonIndex];
if (!lesson || !lesson.steps) return;
const newStepIndex = currentStepIndex + direction;
if (newStepIndex >= 0 && newStepIndex < lesson.steps.length) {
currentStepIndex = newStepIndex;
updateContent();
}
};
let isPresentationFullscreen = false;
window.togglePresentationFullscreen = function() {
const viewer = document.querySelector('.course-viewer');
const header = document.querySelector('.viewer-header');
const sidebar = document.querySelector('.structure-sidebar');
const contentArea = document.querySelector('.content-area');
isPresentationFullscreen = !isPresentationFullscreen;
if (isPresentationFullscreen) {
viewer.classList.add('presentation-fullscreen');
header.style.display = 'none';
sidebar.style.display = 'none';
contentArea.style.width = '100%';
} else {
viewer.classList.remove('presentation-fullscreen');
header.style.display = '';
sidebar.style.display = '';
contentArea.style.width = '';
}
};
function renderMath(element) {
if (typeof renderMathInElement !== 'undefined') {
try {
renderMathInElement(element, {
delimiters: [
{left: '$$', right: '$$', display: true},
{left: '$', right: '$', display: false},
{left: '\\[', right: '\\]', display: true},
{left: '\\(', right: '\\)', display: false}
],
throwOnError: false
});
} catch (e) {
console.error('KaTeX render error:', e);
}
} else {
console.warn('KaTeX not loaded, retrying...');
setTimeout(() => renderMath(element), 100);
}
}
export function afterRender(params) {
updateContent();
document.addEventListener('keydown', handleKeyboard);
}
function initPdfViewer() {
const pdfContainer = document.getElementById('pdf-container');
if (!pdfContainer) {
if (pdfViewerInstance) {
pdfViewerInstance.destroy();
pdfViewerInstance = null;
}
return;
}
const pdfUrl = pdfContainer.dataset.pdfUrl;
if (!pdfUrl) return;
if (pdfViewerInstance) {
pdfViewerInstance.destroy();
}
pdfViewerInstance = new PdfViewer(pdfContainer, pdfUrl, {
showControls: true,
fullscreenButton: true
});
pdfViewerInstance.init();
}
function initTestViewer() {
const testContainer = document.getElementById('test-container');
if (!testContainer) {
if (testViewerInstance) {
testViewerInstance.destroy();
testViewerInstance = null;
}
return;
}
const step = getCurrentStep();
if (!step) return;
if (testViewerInstance) {
testViewerInstance.destroy();
}
testViewerInstance = new TestViewer(testContainer, step.id);
window.currentTestViewer = testViewerInstance;
testViewerInstance.load();
}
function handleKeyboard(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowRight' || e.key === 'PageDown') {
e.preventDefault();
window.navigateStep(1);
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
e.preventDefault();
window.navigateStep(-1);
} else if (e.key === 'Escape' && isPresentationFullscreen) {
e.preventDefault();
window.togglePresentationFullscreen();
}
}
export function cleanup() {
if (autoSaveTimer) clearInterval(autoSaveTimer);
if (vditorInstance) vditorInstance.destroy();
if (pdfViewerInstance) pdfViewerInstance.destroy();
if (testViewerInstance) testViewerInstance.destroy();
document.removeEventListener("keydown", handleKeyboard);
}

View File

@@ -0,0 +1,61 @@
import { coursesAPI } from '../api/courses.js';
import { currentUser } from '../components/auth.js';
export async function render(params) {
const level = new URLSearchParams(window.location.search).get('level');
const courses = await coursesAPI.getAll(level);
return `
<div class="courses-page">
<h1>Course Catalog</h1>
<div class="filters">
<a href="/courses" class="filter-btn ${!level ? 'active' : ''}" data-link>All</a>
<a href="/courses?level=beginner" class="filter-btn ${level === 'beginner' ? 'active' : ''}" data-link>Beginner</a>
<a href="/courses?level=intermediate" class="filter-btn ${level === 'intermediate' ? 'active' : ''}" data-link>Intermediate</a>
<a href="/courses?level=advanced" class="filter-btn ${level === 'advanced' ? 'active' : ''}" data-link>Advanced</a>
</div>
<div class="courses-grid">
${courses.map(course => `
<div class="course-card" data-course-id="${course.id}">
<div class="course-header">
<h3>${course.title}</h3>
<span class="course-level ${course.level}">${course.level}</span>
</div>
<p class="course-description">${course.description}</p>
<div class="course-meta">
<span class="course-duration">${course.duration} hours</span>
<span class="course-date">${new Date(course.created_at).toLocaleDateString()}</span>
</div>
<div class="course-actions">
<a href="/courses/${course.id}" class="btn btn-outline" data-link>View Details</a>
${currentUser ? `
<button class="btn-favorite" data-course-id="${course.id}">
<span class="heart-icon">♡</span> Add to Favorites
</button>
` : ''}
</div>
</div>
`).join('')}
</div>
</div>
`;
}
export async function afterRender() {
// Add event listeners for favorite buttons
document.querySelectorAll('.btn-favorite').forEach(button => {
button.addEventListener('click', async (e) => {
const courseId = parseInt(e.target.dataset.courseId);
try {
await coursesAPI.addFavorite(courseId);
e.target.innerHTML = '<span class="heart-icon">♥</span> Added to Favorites';
e.target.disabled = true;
e.target.classList.add('favorited');
} catch (error) {
alert('Failed to add to favorites: ' + error.message);
}
});
});
}

View File

@@ -0,0 +1,160 @@
import { currentUser } from '../components/auth.js';
import { apiRequest } from '../utils/api.js';
import { updateNav } from '../components/nav.js';
export async function render() {
if (!currentUser) {
return `
<div class="auth-required">
<h2>Требуется авторизация</h2>
<p>Для доступа к инструментам разработчика необходимо войти в систему.</p>
<a href="/login" data-link>Войти</a>
</div>
`;
}
return `
<div class="dev-tools-page">
<div class="dev-tools-container">
<h1>Инструменты разработчика</h1>
<div class="dev-tools-section">
<h2>Статус разработчика</h2>
<p class="section-description">Текущий статус: ${currentUser.is_developer ? '✅ Разработчик (виден раздел Инструменты)' : '❌ Не разработчик'}</p>
<button type="button" class="btn ${currentUser.is_developer ? 'btn-warning' : 'btn-primary'}" id="toggle-developer">
${currentUser.is_developer ? '❌ Отключить права разработчика' : '✅ Включить права разработчика'}
</button>
</div>
<div class="dev-tools-section">
<h2>Управление хранилищем</h2>
<p class="section-description">Очистка локального хранилища приложения</p>
<div class="tools-list">
<div class="tool-item">
<div class="tool-info">
<h3>Очистить localStorage</h3>
<p class="tool-desc">Удалить все данные из localStorage браузера</p>
</div>
<button type="button" class="btn btn-danger" id="clear-local-storage">
Очистить localStorage
</button>
</div>
<div class="tool-item">
<div class="tool-info">
<h3>Очистить session storage</h3>
<p class="tool-desc">Удалить все данные из session storage</p>
</div>
<button type="button" class="btn btn-warning" id="clear-session-storage">
Очистить session storage
</button>
</div>
<div class="tool-item">
<div class="tool-info">
<h3>Скачать localStorage</h3>
<p class="tool-desc">Сохранить содержимое localStorage в файл</p>
</div>
<button type="button" class="btn btn-primary" id="download-local-storage">
Скачать localStorage
</button>
</div>
</div>
</div>
<div id="dev-message" class="message"></div>
</div>
</div>
`;
}
export function afterRender() {
if (!currentUser) return;
// Toggle developer status button
const toggleDeveloperBtn = document.getElementById('toggle-developer');
if (toggleDeveloperBtn) {
toggleDeveloperBtn.addEventListener('click', async () => {
const newStatus = !currentUser.is_developer;
try {
const response = await fetch('/api/auth/me/developer-status', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: JSON.stringify({ is_developer: newStatus })
});
if (response.ok) {
currentUser.is_developer = newStatus;
showMessage(`✅ Статус ${newStatus ? 'включён' : 'отключён'} перезагрузите страницу чтобы увидеть изменения`, 'success');
updateCurrentUserList();
} else {
showMessage('❌ Ошибка updating developer status', 'error');
}
} catch (error) {
console.error('Failed to toggle developer status:', error);
showMessage('❌ Ошибка сети', 'error');
}
});
}
// Clear localStorage button
const clearLocalStorageBtn = document.getElementById('clear-local-storage');
if (clearLocalStorageBtn) {
clearLocalStorageBtn.addEventListener('click', () => {
if (confirm('Вы уверены, что хотите очистить localStorage? Все локально сохранённые данные будут удалены.')) {
localStorage.clear();
showMessage('✅ localStorage очищен', 'success');
}
});
}
// Clear session storage button
const clearSessionStorageBtn = document.getElementById('clear-session-storage');
if (clearSessionStorageBtn) {
clearSessionStorageBtn.addEventListener('click', () => {
if (confirm('Вы уверены, что хотите очистить session storage?')) {
sessionStorage.clear();
showMessage('✅ Session storage очищен', 'success');
}
});
}
// Download localStorage button
const downloadLocalStorageBtn = document.getElementById('download-local-storage');
if (downloadLocalStorageBtn) {
downloadLocalStorageBtn.addEventListener('click', () => {
const data = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
data[key] = localStorage.getItem(key);
}
const dataStr = JSON.stringify(data, null, 2);
const blob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `localStorage_backup_${new Date().toISOString()}.json`;
a.click();
URL.revokeObjectURL(url);
showMessage('✅ localStorage скачивается', 'success');
});
}
}
function showMessage(text, type) {
const messageEl = document.getElementById('dev-message');
if (messageEl) {
messageEl.textContent = text;
messageEl.className = `message ${type}`;
}
}
function updateCurrentUserList() {
updateNav();
}

View File

@@ -0,0 +1,86 @@
import { coursesAPI } from '../api/courses.js';
import { currentUser } from '../components/auth.js';
export async function render() {
if (!currentUser) {
return `
<div class="auth-required">
<h2>Authentication Required</h2>
<p>Please <a href="/login" data-link>login</a> to view your favorite courses.</p>
</div>
`;
}
const favorites = await coursesAPI.getFavorites();
const favoriteCourseIds = favorites.map(fav => fav.course_id);
// Fetch all courses to get details
const allCourses = await coursesAPI.getAll();
const favoriteCourses = allCourses.filter(course =>
favoriteCourseIds.includes(course.id)
);
return `
<div class="favorites-page">
<h1>My Favorite Courses</h1>
${favoriteCourses.length === 0 ? `
<div class="empty-state">
<p>You haven't added any courses to favorites yet.</p>
<a href="/courses" class="btn" data-link>Browse Courses</a>
</div>
` : `
<div class="courses-grid">
${favoriteCourses.map(course => `
<div class="course-card" data-course-id="${course.id}">
<div class="course-header">
<h3>${course.title}</h3>
<span class="course-level ${course.level}">${course.level}</span>
</div>
<p class="course-description">${course.description}</p>
<div class="course-meta">
<span class="course-duration">${course.duration} hours</span>
<span class="course-date">${new Date(course.created_at).toLocaleDateString()}</span>
</div>
<div class="course-actions">
<a href="/courses/${course.id}" class="btn btn-outline" data-link>View Details</a>
<button class="btn-remove-favorite" data-course-id="${course.id}">
Remove from Favorites
</button>
</div>
</div>
`).join('')}
</div>
`}
</div>
`;
}
export async function afterRender() {
if (!currentUser) return;
// Add event listeners for remove favorite buttons
document.querySelectorAll('.btn-remove-favorite').forEach(button => {
button.addEventListener('click', async (e) => {
const courseId = parseInt(e.target.dataset.courseId);
if (confirm('Remove this course from favorites?')) {
try {
await coursesAPI.removeFavorite(courseId);
e.target.closest('.course-card').remove();
// If no cards left, show empty state
if (document.querySelectorAll('.course-card').length === 0) {
document.querySelector('.courses-grid').innerHTML = `
<div class="empty-state">
<p>You haven't added any courses to favorites yet.</p>
<a href="/courses" class="btn" data-link>Browse Courses</a>
</div>
`;
}
} catch (error) {
alert('Failed to remove from favorites: ' + error.message);
}
}
});
});
}

618
frontend/src/views/home.js Normal file
View File

@@ -0,0 +1,618 @@
export async function render() {
return `
<div class="landing">
<!-- Hero Section -->
<section class="hero">
<div class="hero-bg">
<canvas id="particles-canvas"></canvas>
</div>
<div class="container hero-content">
<div class="hero-text">
<h1 class="hero-title">
Умное корпоративное обучение<br>
<span class="gradient-text">в формате Markdown</span>
</h1>
<p class="hero-subtitle">
Создавайте курсы так же быстро, как пишете текст.
Автоматическая синхронизация прогресса и аналитики
напрямую с вашим корпоративным контуром 1С.
</p>
<div class="hero-buttons">
<button class="btn btn-primary btn-lg" onclick="scrollToSection('demo')">
Запросить демо
</button>
<a href="#features" class="btn btn-outline btn-lg">
Возможности платформы
</a>
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-value">10x</span>
<span class="stat-label">быстрее создание курсов</span>
</div>
<div class="stat">
<span class="stat-value">99.9%</span>
<span class="stat-label">uptime</span>
</div>
<div class="stat">
<span class="stat-value">50+</span>
<span class="stat-label">интеграций</span>
</div>
</div>
</div>
<div class="hero-visual">
<div class="editor-demo">
<div class="editor-header">
<div class="editor-dots">
<span></span><span></span><span></span>
</div>
<span class="editor-title">course.md</span>
</div>
<div class="editor-body">
<div class="editor-input" id="editor-input">
<pre><code><span class="line"><span class="md-hash">#</span> <span class="md-heading">Введение в Python</span></span>
<span class="line"></span>
<span class="line"><span class="md-hash">##</span> <span class="md-heading">Переменные и типы данных</span></span>
<span class="line"></span>
<span class="line"><span class="md-text">Python — язык с </span><span class="md-bold">**динамической**</span><span class="md-text"> типизацией.</span></span>
<span class="line"></span>
<span class="line"><span class="md-code">\`\`\`python</span></span>
<span class="line"><span class="code-keyword">name</span> = <span class="code-string">"Alice"</span></span>
<span class="line"><span class="code-keyword">age</span> = <span class="code-number">30</span></span>
<span class="line"><span class="md-code">\`\`\`</span></span>
<span class="line"></span>
<span class="line"><span class="md-latex">$x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$</span></span></code></pre>
</div>
<div class="editor-arrow">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="editor-output">
<div class="preview-content">
<h1 class="preview-title">Введение в Python</h1>
<h2 class="preview-subtitle">Переменные и типы данных</h2>
<p class="preview-text">Python — язык с <strong>динамической</strong> типизацией.</p>
<div class="preview-code">
<code><span class="code-keyword">name</span> = <span class="code-string">"Alice"</span></code>
<code><span class="code-keyword">age</span> = <span class="code-number">30</span></code>
</div>
<div class="preview-formula">x = (-b ± √(b²-4ac)) / 2a</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="hero-scroll">
<span>Листайте вниз</span>
<div class="scroll-indicator"></div>
</div>
</section>
<!-- Simplicity Section -->
<section class="section simplicity" id="features">
<div class="container">
<div class="section-header">
<span class="section-badge">Простота</span>
<h2 class="section-title">Создавайте курсы за минуты, не часы</h2>
<p class="section-subtitle">
Забудьте о громоздких визуальных конструкторах.
Используйте привычный Markdown-синтаксис для создания интерактивного контента.
</p>
</div>
<div class="simplicity-grid">
<div class="simplicity-card">
<div class="card-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<path d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 2V8H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 13H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 17H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3>Markdown-first</h3>
<p>Пишите контент в привычном формате. Платформа автоматически преобразует его в интерактивные модули.</p>
</div>
<div class="simplicity-card">
<div class="card-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
<rect x="14" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
<rect x="14" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
<rect x="3" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
</svg>
</div>
<h3>Автоструктура</h3>
<p>Заголовки автоматически формируют модули и уроки. Не нужно настраивать навигацию вручную.</p>
</div>
<div class="simplicity-card">
<div class="card-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3>Мультимедиа</h3>
<p>Встраивайте изображения, видео, код с подсветкой и математические формулы LaTeX.</p>
</div>
<div class="simplicity-card">
<div class="card-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3>Мгновенный предпросмотр</h3>
<p>Видите результат в реальном времени. WYSIWYG-режим для тех, кто предпочитает визуальное редактирование.</p>
</div>
</div>
<div class="simplicity-demo">
<div class="transform-visual">
<div class="transform-item text">
<span class="transform-icon">📝</span>
<span>Плоский текст</span>
</div>
<div class="transform-arrow">
<svg width="100" height="24" viewBox="0 0 100 24">
<defs>
<linearGradient id="arrow-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#10b981"/>
<stop offset="100%" style="stop-color:#6366f1"/>
</linearGradient>
</defs>
<path d="M0 12 H85 M80 6 L90 12 L80 18" stroke="url(#arrow-grad)" stroke-width="2" fill="none"/>
</svg>
</div>
<div class="transform-item interactive">
<span class="transform-icon">🎓</span>
<span>Интерактивный курс</span>
</div>
</div>
</div>
</div>
</section>
<!-- Integration Section -->
<section class="section integration">
<div class="container">
<div class="section-header">
<span class="section-badge">Интеграция</span>
<h2 class="section-title">Глубокая интеграция с 1С</h2>
<p class="section-subtitle">
Автоматический обмен данными между платформой обучения
и корпоративными системами учёта. Никакого ручного переноса.
</p>
</div>
<div class="integration-flow">
<div class="flow-center">
<div class="platform-hub">
<div class="hub-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<span class="hub-label">Платформа<br>обучения</span>
</div>
</div>
<div class="flow-left">
<div class="flow-item" data-direction="to">
<div class="flow-icon">📊</div>
<div class="flow-content">
<h4>Результаты тестов</h4>
<p>Автоматическая передача оценок и сертификатов</p>
</div>
</div>
<div class="flow-item" data-direction="to">
<div class="flow-icon">⏱️</div>
<div class="flow-content">
<h4>Время обучения</h4>
<p>Учёт рабочего времени в ЗУП</p>
</div>
</div>
<div class="flow-item" data-direction="to">
<div class="flow-icon">📈</div>
<div class="flow-content">
<h4>Метрики вовлечённости</h4>
<p>Аналитика для HR-отчётности</p>
</div>
</div>
</div>
<div class="flow-right">
<div class="flow-item" data-direction="from">
<div class="flow-icon">👥</div>
<div class="flow-content">
<h4>Сотрудники</h4>
<p>Синхронизация оргструктуры</p>
</div>
</div>
<div class="flow-item" data-direction="from">
<div class="flow-icon">🏢</div>
<div class="flow-content">
<h4>Подразделения</h4>
<p>Автоматическое назначение курсов</p>
</div>
</div>
<div class="flow-item" data-direction="from">
<div class="flow-icon">📋</div>
<div class="flow-content">
<h4>Должности</h4>
<p>Персональные траектории обучения</p>
</div>
</div>
</div>
<div class="flow-1c">
<div class="c1-badge">
<span class="c1-logo">1С</span>
<span class="c1-products">ЗУП • ERP • CRM</span>
</div>
</div>
<svg class="flow-lines" viewBox="0 0 800 400">
<defs>
<linearGradient id="line-grad-left" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#10b981"/>
<stop offset="100%" style="stop-color:#6366f1"/>
</linearGradient>
<linearGradient id="line-grad-right" x1="100%" y1="0%" x2="0%" y2="0%">
<stop offset="0%" style="stop-color:#6366f1"/>
<stop offset="100%" style="stop-color:#10b981"/>
</linearGradient>
</defs>
<path class="flow-line line-left" d="M150 80 Q300 80 400 200" stroke="url(#line-grad-left)" fill="none"/>
<path class="flow-line line-left" d="M150 200 Q250 200 400 200" stroke="url(#line-grad-left)" fill="none"/>
<path class="flow-line line-left" d="M150 320 Q300 320 400 200" stroke="url(#line-grad-left)" fill="none"/>
<path class="flow-line line-right" d="M650 80 Q500 80 400 200" stroke="url(#line-grad-right)" fill="none"/>
<path class="flow-line line-right" d="M650 200 Q550 200 400 200" stroke="url(#line-grad-right)" fill="none"/>
<path class="flow-line line-right" d="M650 320 Q500 320 400 200" stroke="url(#line-grad-right)" fill="none"/>
</svg>
</div>
<div class="integration-benefits">
<div class="benefit">
<span class="benefit-value">100%</span>
<span class="benefit-label">автоматизация отчётности</span>
</div>
<div class="benefit">
<span class="benefit-value">0</span>
<span class="benefit-label">ручного ввода данных</span>
</div>
<div class="benefit">
<span class="benefit-value">24/7</span>
<span class="benefit-label">синхронизация в реальном времени</span>
</div>
</div>
</div>
</section>
<!-- Technologies Section -->
<section class="section technologies">
<div class="container">
<div class="section-header">
<span class="section-badge">Технологии</span>
<h2 class="section-title">Умное обучение для современных команд</h2>
</div>
<div class="tech-grid">
<div class="tech-card featured">
<div class="tech-visual">
<div class="adaptive-diagram">
<svg viewBox="0 0 200 150">
<defs>
<linearGradient id="adapt-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#10b981"/>
<stop offset="100%" style="stop-color:#6366f1"/>
</linearGradient>
</defs>
<path class="adapt-path" d="M20 75 Q50 30 80 75 T140 75 T200 75" stroke="url(#adapt-grad)" stroke-width="3" fill="none"/>
<circle class="adapt-node" cx="50" cy="55" r="8"/>
<circle class="adapt-node" cx="100" cy="75" r="8"/>
<circle class="adapt-node" cx="150" cy="55" r="8"/>
</svg>
</div>
</div>
<h3>Динамическая адаптация</h3>
<p>Алгоритмы платформы анализируют результаты промежуточных тестов и перестраивают траекторию обучения для каждого сотрудника индивидуально.</p>
</div>
<div class="tech-card">
<div class="tech-visual">
<div class="multi-tenant-visual">
<div class="tenant tenant-1">Филиал А</div>
<div class="tenant tenant-2">Филиал Б</div>
<div class="tenant tenant-3">HQ</div>
<div class="tenant-center">Центр управления</div>
</div>
</div>
<h3>Мультиарендность</h3>
<p>Изолированные личные кабинеты для разных филиалов и отделов с единой точкой администрирования.</p>
</div>
<div class="tech-card">
<div class="tech-visual">
<div class="performance-meters">
<div class="meter">
<div class="meter-fill" style="--fill: 95%"></div>
<span>SSR</span>
</div>
<div class="meter">
<div class="meter-fill" style="--fill: 90%"></div>
<span>Cache</span>
</div>
<div class="meter">
<div class="meter-fill" style="--fill: 99%"></div>
<span>CDN</span>
</div>
</div>
</div>
<h3>Высокая производительность</h3>
<p>Плавная работа на любых устройствах благодаря SSR, умному кэшированию и глобальной CDN-сети.</p>
</div>
</div>
</div>
</section>
<!-- Security Section -->
<section class="section security">
<div class="container">
<div class="security-content">
<div class="security-text">
<span class="section-badge">Безопасность</span>
<h2 class="section-title">Защита корпоративных данных</h2>
<p class="section-subtitle">
Гибкая ролевая модель доступа и соответствие требованиям
информационной безопасности корпоративного сектора.
</p>
<ul class="security-features">
<li>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>RBAC — ролевая модель доступа</span>
</li>
<li>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>JWT-токены с ротацией</span>
</li>
<li>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Шифрование данных в покое и в транзите</span>
</li>
<li>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Audit Log — полный аудит действий</span>
</li>
<li>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Возможность on-premise развертывания</span>
</li>
</ul>
</div>
<div class="security-visual">
<div class="shield-container">
<svg class="shield-svg" viewBox="0 0 200 240">
<defs>
<linearGradient id="shield-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1"/>
<stop offset="100%" style="stop-color:#10b981"/>
</linearGradient>
</defs>
<path class="shield-path" d="M100 10 L180 50 L180 120 Q180 200 100 230 Q20 200 20 120 L20 50 Z" stroke="url(#shield-grad)" stroke-width="4" fill="none"/>
<path class="shield-check" d="M70 120 L90 140 L130 100" stroke="url(#shield-grad)" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="shield-glow"></div>
</div>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="section cta" id="demo">
<div class="container">
<div class="cta-content">
<h2 class="cta-title">Готовы оцифровать и автоматизировать обучение?</h2>
<p class="cta-subtitle">
Получите персональную демонстрацию платформы и узнайте,
как оптимизировать корпоративное обучение в вашей компании.
</p>
<form class="cta-form" id="demo-form">
<div class="form-row">
<input type="text" placeholder="Ваше имя" required>
<input type="email" placeholder="Корпоративный email" required>
</div>
<div class="form-row">
<input type="text" placeholder="Компания">
<input type="tel" placeholder="Телефон">
</div>
<textarea placeholder="Расскажите о ваших задачах обучения..." rows="3"></textarea>
<button type="submit" class="btn btn-primary btn-lg btn-full">
Запросить демо
</button>
</form>
<p class="cta-note">
Нажимая кнопку, вы соглашаетесь с
<a href="#">политикой конфиденциальности</a>
</p>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<div class="footer-logo">
<span class="logo-icon">📚</span>
<span class="logo-text">LearnMD</span>
</div>
<p>Умная платформа корпоративного обучения в формате Markdown</p>
</div>
<div class="footer-links">
<h4>Продукт</h4>
<a href="#features">Возможности</a>
<a href="#">Тарифы</a>
<a href="#">Интеграции</a>
<a href="#">API</a>
</div>
<div class="footer-links">
<h4>Компания</h4>
<a href="#">О нас</a>
<a href="#">Блог</a>
<a href="#">Карьера</a>
<a href="#">Контакты</a>
</div>
<div class="footer-links">
<h4>Поддержка</h4>
<a href="#">Документация</a>
<a href="#">FAQ</a>
<a href="#">Статус системы</a>
</div>
<div class="footer-links">
<h4>Юридическое</h4>
<a href="#">Политика конфиденциальности</a>
<a href="#">Условия использования</a>
<a href="#">SLA</a>
</div>
</div>
<div class="footer-bottom">
<p>&copy; 2024 LearnMD. Все права защищены.</p>
</div>
</div>
</footer>
</div>
`;
}
export function afterRender() {
initParticles();
initAnimations();
initFormHandler();
}
window.scrollToSection = function(id) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
};
function initParticles() {
const canvas = document.getElementById('particles-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let particles = [];
let animationId;
function resize() {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
}
function createParticles() {
particles = [];
const count = Math.floor((canvas.width * canvas.height) / 15000);
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
radius: Math.random() * 2 + 1,
opacity: Math.random() * 0.5 + 0.2
});
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach((p, i) => {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(99, 102, 241, ${p.opacity})`;
ctx.fill();
particles.slice(i + 1).forEach(p2 => {
const dx = p.x - p2.x;
const dy = p.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 120) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `rgba(99, 102, 241, ${0.15 * (1 - dist / 120)})`;
ctx.stroke();
}
});
});
animationId = requestAnimationFrame(draw);
}
resize();
createParticles();
draw();
window.addEventListener('resize', () => {
resize();
createParticles();
});
}
function initAnimations() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.section, .simplicity-card, .tech-card, .flow-item').forEach(el => {
observer.observe(el);
});
}
function initFormHandler() {
const form = document.getElementById('demo-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = form.querySelector('button[type="submit"]');
const originalText = btn.textContent;
btn.textContent = 'Отправка...';
btn.disabled = true;
await new Promise(r => setTimeout(r, 1500));
btn.textContent = 'Заявка отправлена ✓';
btn.classList.add('success');
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
btn.classList.remove('success');
form.reset();
}, 3000);
});
}

View File

@@ -0,0 +1,775 @@
import { currentUser } from '../components/auth.js';
import { coursesAPI } from '../api/courses.js';
import { loadVditor, initVditorEditor } from '../editors/vditorLoader.js';
import { TestEditor } from '../course-builder/TestEditor.js';
let currentLesson = null;
let currentStepIndex = 0;
let vditorInstance = null;
let testEditorInstance = null;
let isDirty = false;
let autoSaveTimer = null;
let moduleIndex = null;
let lessonIndex = null;
const stepTypeLabels = {
theory: 'Теория',
practice: 'Практика',
lab: 'Лабораторная',
test: 'Тест',
presentation: 'Презентация'
};
const stepTypeIcons = {
theory: '📖',
practice: '✍️',
lab: '🔬',
test: '📝',
presentation: '📊'
};
export async function render(params) {
if (!currentUser) {
return `
<div class="auth-required">
<h2>Требуется авторизация</h2>
<p>Для редактирования урока необходимо войти в систему.</p>
<a href="/login" data-link>Войти</a>
</div>`;
}
const urlParams = new URLSearchParams(window.location.search);
moduleIndex = parseInt(urlParams.get('moduleIndex'));
lessonIndex = parseInt(urlParams.get('lessonIndex'));
return `
<div class="lesson-editor-page">
<div class="editor-header">
<div class="header-left">
<a href="/course-builder" data-link class="back-link">← К конструктору</a>
<div class="lesson-title-input">
<input type="text" id="lesson-title" placeholder="Название урока" />
</div>
</div>
<div class="header-right">
<span class="save-status" id="save-status">Сохранено</span>
<button class="btn btn-outline" id="preview-btn">👁️ Предпросмотр</button>
<button class="btn btn-primary" id="save-lesson-btn">Сохранить</button>
</div>
</div>
<div class="editor-body">
<aside class="steps-sidebar">
<div class="sidebar-header">
<h3>Шаги урока</h3>
<button class="btn btn-sm btn-primary" id="add-step-btn">+ Добавить</button>
</div>
<div class="steps-list" id="steps-list">
<div class="loading-steps">Загрузка...</div>
</div>
</aside>
<main class="editor-main">
<div class="step-editor" id="step-editor">
<div class="editor-placeholder">Выберите шаг для редактирования</div>
</div>
</main>
</div>
</div>
`;
}
export async function afterRender(params) {
const lessonId = parseInt(params.lessonId);
await loadLesson(lessonId);
document.getElementById('save-lesson-btn').addEventListener('click', saveLesson);
document.getElementById('add-step-btn').addEventListener('click', addNewStep);
document.getElementById('preview-btn').addEventListener('click', showPreviewModal);
document.getElementById('lesson-title').addEventListener('input', markDirty);
document.addEventListener('keydown', handleKeyboard);
window.addEventListener('beforeunload', (e) => {
if (isDirty) {
e.preventDefault();
e.returnValue = '';
}
});
startAutoSave();
}
async function loadLesson(lessonId) {
try {
console.log('Loading lesson with ID:', lessonId);
currentLesson = await coursesAPI.getLesson(lessonId);
console.log('Loaded lesson:', currentLesson);
console.log('Steps count:', currentLesson?.steps?.length);
if (!currentLesson) {
document.getElementById('steps-list').innerHTML = '<p>Урок не найден</p>';
return;
}
document.getElementById('lesson-title').value = currentLesson.title || '';
renderStepsList();
if (currentLesson.steps && currentLesson.steps.length > 0) {
console.log('Selecting first step');
selectStep(0);
} else {
console.log('No steps found');
}
} catch (error) {
console.error('Failed to load lesson:', error);
document.getElementById('steps-list').innerHTML = `<p>Ошибка загрузки: ${error.message}</p>`;
}
}
function renderStepsList() {
const container = document.getElementById('steps-list');
if (!currentLesson.steps || currentLesson.steps.length === 0) {
container.innerHTML = '<div class="empty-steps"><p>Нет шагов</p></div>';
return;
}
container.innerHTML = currentLesson.steps.map((step, index) => `
<div class="step-item ${index === currentStepIndex ? 'active' : ''}"
data-step-index="${index}"
draggable="true">
<div class="step-drag-handle">⋮⋮</div>
<div class="step-info">
<span class="step-icon">${stepTypeIcons[step.step_type] || '📄'}</span>
<span class="step-number">${index + 1}</span>
<span class="step-title">${step.title || stepTypeLabels[step.step_type]}</span>
</div>
<div class="step-actions">
<button class="btn-icon delete-step" data-step-index="${index}" title="Удалить">×</button>
</div>
</div>
`).join('');
container.querySelectorAll('.step-item').forEach(item => {
const index = parseInt(item.dataset.stepIndex);
item.addEventListener('click', (e) => {
if (!e.target.classList.contains('delete-step') && !e.target.classList.contains('step-drag-handle')) {
selectStep(index);
}
});
item.querySelector('.delete-step').addEventListener('click', (e) => {
e.stopPropagation();
deleteStep(index);
});
});
initDragAndDrop();
}
async function selectStep(index) {
if (index < 0 || index >= currentLesson.steps.length) return;
if (isDirty && currentStepIndex !== index) {
await saveCurrentStep();
}
currentStepIndex = index;
renderStepsList();
renderStepEditor();
}
function renderStepEditor() {
console.log('renderStepEditor called, currentStepIndex:', currentStepIndex);
const container = document.getElementById('step-editor');
const step = currentLesson.steps[currentStepIndex];
console.log('Step to render:', step);
if (!step) {
container.innerHTML = '<div class="editor-placeholder">Выберите шаг для редактирования</div>';
return;
}
console.log('Rendering step editor for step type:', step.step_type);
container.innerHTML = `
<div class="step-editor-content">
<div class="form-group">
<label for="step-type">Тип шага</label>
<select id="step-type" class="form-control">
<option value="theory" ${step.step_type === 'theory' ? 'selected' : ''}>Теория</option>
<option value="presentation" ${step.step_type === 'presentation' ? 'selected' : ''}>Презентация (PDF)</option>
<option value="practice" ${step.step_type === 'practice' ? 'selected' : ''}>Практика</option>
<option value="lab" ${step.step_type === 'lab' ? 'selected' : ''}>Лабораторная</option>
<option value="test" ${step.step_type === 'test' ? 'selected' : ''}>Тест</option>
</select>
</div>
<div class="form-group">
<label for="step-title-input">Заголовок шага</label>
<input type="text" id="step-title-input" class="form-control"
value="${step.title || ''}" placeholder="Заголовок шага (опционально)">
</div>
<div id="step-content-editor">
${renderStepContentEditor(step)}
</div>
</div>
`;
document.getElementById('step-type').addEventListener('change', onStepTypeChange);
document.getElementById('step-title-input').addEventListener('input', markDirty);
if (step.step_type === 'theory') {
console.log('Initializing Vditor with content:', step.content?.substring(0, 50) + '...');
initVditor(step.content || '');
} else if (step.step_type === 'presentation') {
initPdfUpload();
} else if (step.step_type === 'test') {
initTestEditor();
}
}
function renderStepContentEditor(step) {
switch (step.step_type) {
case 'theory':
return `
<div class="form-group">
<label>Содержание</label>
<div id="vditor-container"></div>
</div>
`;
case 'presentation':
return `
<div class="form-group">
<label>PDF файл презентации</label>
<div class="file-upload-area" id="pdf-upload-area">
${step.file_url ? `
<div class="file-preview">
<span class="file-name">${step.file_url.split('/').pop()}</span>
<button class="btn btn-sm btn-outline" id="change-pdf-btn">Изменить</button>
</div>
` : `
<div class="upload-prompt">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" stroke-width="2"/>
<polyline points="14,2 14,8 20,8" stroke-width="2"/>
<line x1="12" y1="18" x2="12" y2="12" stroke-width="2"/>
<polyline points="9,15 12,12 15,15" stroke-width="2"/>
</svg>
<p>Перетащите PDF файл сюда или</p>
<input type="file" id="pdf-file-input" accept=".pdf" style="display: none;">
<button class="btn btn-outline" id="select-pdf-btn">Выбрать файл</button>
</div>
`}
</div>
</div>
`;
case 'test':
return `<div id="test-editor-container"></div>`;
default:
return `
<div class="form-group">
<label>Содержание</label>
<textarea id="step-content-textarea" class="form-control" rows="10"
placeholder="Введите содержание шага">${step.content || ''}</textarea>
</div>
`;
}
}
async function onStepTypeChange(e) {
const newType = e.target.value;
const step = currentLesson.steps[currentStepIndex];
if (step.step_type === 'theory' && vditorInstance) {
step.content = vditorInstance.getValue();
}
step.step_type = newType;
step.content = '';
step.file_url = null;
document.getElementById('step-content-editor').innerHTML = renderStepContentEditor(step);
if (newType === 'theory') {
initVditor('');
} else if (newType === 'presentation') {
initPdfUpload();
} else if (newType === 'test') {
initTestEditor();
} else {
document.getElementById('step-content-textarea')?.addEventListener('input', markDirty);
}
markDirty();
}
function initVditor(initialContent) {
if (typeof Vditor === 'undefined') {
loadVditor().then(() => createVditor(initialContent));
} else {
createVditor(initialContent);
}
}
function createVditor(initialContent) {
console.log('createVditor called with content length:', initialContent?.length);
if (vditorInstance) {
console.log('Destroying previous vditor instance');
vditorInstance.destroy();
vditorInstance = null;
}
const container = document.getElementById('vditor-container');
if (!container) {
console.error('vditor-container not found');
return;
}
console.log('Creating new Vditor instance');
vditorInstance = new Vditor('vditor-container', {
height: 500,
placeholder: 'Введите содержание шага...',
theme: 'classic',
lang: 'ru_RU',
value: initialContent || '',
cache: { enable: false },
mode: 'wysiwyg',
toolbar: [
'headings', 'bold', 'italic', 'strike', '|',
'list', 'ordered-list', 'check', '|',
'quote', 'code', 'inline-code', '|',
'table', '|',
'undo', 'redo', '|',
'edit-mode', 'preview', 'fullscreen', 'help'
],
preview: {
hljs: { enable: true, lineNumber: true },
markdown: { toc: true }
},
counter: { enable: true },
input: () => {
console.log('Vditor input event');
markDirty();
}
});
console.log('Vditor instance created successfully');
}
function initPdfUpload() {
const uploadArea = document.getElementById('pdf-upload-area');
const fileInput = document.getElementById('pdf-file-input');
const selectBtn = document.getElementById('select-pdf-btn');
if (!uploadArea || !fileInput) return;
selectBtn?.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type === 'application/pdf') {
handlePdfUpload(file);
}
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) handlePdfUpload(file);
});
}
async function handlePdfUpload(file) {
console.log('handlePdfUpload called, file:', file.name, 'size:', file.size);
if (file.size > 50 * 1024 * 1024) {
alert('Файл слишком большой (максимум 50 МБ)');
return;
}
try {
console.log('Uploading PDF file...');
const response = await coursesAPI.uploadFile(file);
console.log('Upload response:', response);
if (response && response.url) {
currentLesson.steps[currentStepIndex].file_url = response.url;
renderStepEditor();
markDirty();
} else {
throw new Error('Неверный ответ сервера');
}
} catch (error) {
console.error('Upload error:', error);
alert('Ошибка загрузки файла: ' + error.message);
}
}
async function initTestEditor() {
console.log('[lessonEditor] initTestEditor called');
const container = document.getElementById('test-editor-container');
console.log('[lessonEditor] Container:', container);
if (!container) return;
const step = currentLesson.steps[currentStepIndex];
console.log('[lessonEditor] Current step:', step);
if (!step || !step.id) {
console.warn('[lessonEditor] Step not saved yet');
container.innerHTML = '<p class="test-placeholder">Сохраните шаг, чтобы редактировать тест</p>';
return;
}
console.log('[lessonEditor] Creating TestEditor for step', step.id);
testEditorInstance = new TestEditor(step.id);
try {
console.log('[lessonEditor] Loading questions...');
await testEditorInstance.loadQuestions();
console.log('[lessonEditor] Questions loaded, rendering...');
testEditorInstance.render(container);
console.log('[lessonEditor] TestEditor initialized successfully');
} catch (error) {
console.error('[lessonEditor] Failed to load test editor:', error);
container.innerHTML = '<p class="test-error">Ошибка загрузки редактора теста: ' + error.message + '</p>';
}
}
function markDirty() {
isDirty = true;
document.getElementById('save-status').textContent = 'Не сохранено';
document.getElementById('save-status').classList.add('dirty');
}
function markClean() {
isDirty = false;
document.getElementById('save-status').textContent = 'Сохранено';
document.getElementById('save-status').classList.remove('dirty');
}
async function saveCurrentStep() {
const step = currentLesson.steps[currentStepIndex];
if (!step) return;
if (step.step_type === 'theory' && vditorInstance) {
step.content = vditorInstance.getValue();
} else if (step.step_type !== 'presentation' && step.step_type !== 'theory') {
const textarea = document.getElementById('step-content-textarea');
if (textarea) step.content = textarea.value;
}
const titleInput = document.getElementById('step-title-input');
if (titleInput) step.title = titleInput.value;
const typeSelect = document.getElementById('step-type');
if (typeSelect) step.step_type = typeSelect.value;
try {
if (step.id) {
await coursesAPI.updateStep(step.id, {
type: step.step_type,
title: step.title,
content: step.content,
file_url: step.file_url,
order: step.order_index
});
markClean();
}
} catch (error) {
console.error('Failed to save step:', error);
alert('Ошибка сохранения шага: ' + error.message);
}
}
async function saveLesson() {
await saveCurrentStep();
const title = document.getElementById('lesson-title').value.trim();
if (!title) {
alert('Введите название урока');
return;
}
try {
if (currentLesson.id) {
await coursesAPI.updateLesson(currentLesson.id, { title });
}
markClean();
alert('Урок сохранен');
} catch (error) {
alert('Ошибка сохранения: ' + error.message);
}
}
async function addNewStep() {
const newStep = {
step_type: 'theory',
title: null,
content: '',
file_url: null,
order_index: currentLesson.steps ? currentLesson.steps.length : 0
};
try {
const createdStep = await coursesAPI.createStep({
lesson_id: currentLesson.id,
type: newStep.step_type,
title: newStep.title,
content: newStep.content,
order: newStep.order_index
});
if (createdStep) {
newStep.id = createdStep.id;
}
if (!currentLesson.steps) currentLesson.steps = [];
currentLesson.steps.push(newStep);
renderStepsList();
selectStep(currentLesson.steps.length - 1);
} catch (error) {
alert('Ошибка создания шага: ' + error.message);
}
}
async function deleteStep(index) {
if (!confirm('Удалить этот шаг?')) return;
const step = currentLesson.steps[index];
try {
if (step.id) {
await coursesAPI.deleteStep(step.id);
}
currentLesson.steps.splice(index, 1);
currentLesson.steps.forEach((s, i) => {
s.order_index = i;
});
if (currentStepIndex >= currentLesson.steps.length) {
currentStepIndex = Math.max(0, currentLesson.steps.length - 1);
}
renderStepsList();
if (currentLesson.steps.length > 0) {
selectStep(currentStepIndex);
} else {
document.getElementById('step-editor').innerHTML = '<div class="editor-placeholder">Нет шагов</div>';
document.getElementById('preview-content').innerHTML = '<div class="preview-placeholder">Предпросмотр появится здесь</div>';
}
} catch (error) {
alert('Ошибка удаления шага: ' + error.message);
}
}
function showPreviewModal() {
const step = currentLesson.steps[currentStepIndex];
if (!step) {
alert('Выберите шаг для предпросмотра');
return;
}
let content = '';
if (step.step_type === 'theory') {
if (vditorInstance) {
content = vditorInstance.getHTML();
} else if (step.content_html) {
content = step.content_html;
} else {
content = `<div class="markdown-content">${step.content || '<p>Нет содержимого</p>'}</div>`;
}
} else if (step.step_type === 'presentation' && step.file_url) {
content = `
<div class="pdf-preview-container">
<div class="pdf-preview-note">
<span class="file-icon">📊</span>
<span class="file-name">${step.file_url.split('/').pop()}</span>
</div>
<p class="pdf-preview-hint">PDF файл будет отображён в режиме просмотра курса</p>
</div>
`;
} else {
content = `<div class="markdown-content">${step.content || '<p>Нет содержимого</p>'}</div>`;
}
const modalHtml = `
<div class="modal-overlay" id="preview-modal">
<div class="modal" style="max-width: 900px; max-height: 90vh;">
<div class="modal-header">
<h3>Предпросмотр шага</h3>
<button class="modal-close" id="close-preview-modal">×</button>
</div>
<div class="modal-body" style="max-height: calc(90vh - 120px); overflow-y: auto;">
<div class="preview-step">
<div class="preview-step-header">
<span class="preview-step-type">${stepTypeIcons[step.step_type]} ${stepTypeLabels[step.step_type]}</span>
<h4>${step.title || 'Без названия'}</h4>
</div>
<div class="preview-step-body">
${content}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="close-preview-btn">Закрыть</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById('preview-modal');
document.getElementById('close-preview-modal').addEventListener('click', () => modal.remove());
document.getElementById('close-preview-btn').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
if (typeof renderMathInElement !== 'undefined') {
renderMathInElement(modal.querySelector('.preview-step-body'), {
delimiters: [
{left: '$$', right: '$$', display: true},
{left: '$', right: '$', display: false}
],
throwOnError: false
});
}
}
function initDragAndDrop() {
const container = document.getElementById('steps-list');
const items = container.querySelectorAll('.step-item');
items.forEach(item => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
});
}
let draggedItem = null;
function handleDragStart(e) {
draggedItem = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const afterElement = getDragAfterElement(this.parentElement, e.clientY);
if (afterElement == null) {
this.parentElement.appendChild(draggedItem);
} else {
this.parentElement.insertBefore(draggedItem, afterElement);
}
}
function handleDrop(e) {
e.preventDefault();
const items = Array.from(this.parentElement.querySelectorAll('.step-item'));
const newOrder = items.map(item => parseInt(item.dataset.stepIndex));
const reorderedSteps = newOrder.map(oldIndex => currentLesson.steps[oldIndex]);
currentLesson.steps = reorderedSteps;
currentLesson.steps.forEach((step, index) => {
step.order_index = index;
});
const currentStepId = currentLesson.steps[currentStepIndex]?.id;
currentStepIndex = currentLesson.steps.findIndex(s => s.id === currentStepId);
renderStepsList();
markDirty();
}
function handleDragEnd(e) {
this.classList.remove('dragging');
draggedItem = null;
}
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.step-item:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function startAutoSave() {
autoSaveTimer = setInterval(() => {
if (isDirty) {
saveCurrentStep();
}
}, 30000);
}
function handleKeyboard(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
saveLesson();
} else if (e.key === 'n') {
e.preventDefault();
addNewStep();
}
}
if (e.key === 'ArrowUp' && e.ctrlKey) {
e.preventDefault();
selectStep(Math.max(0, currentStepIndex - 1));
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
e.preventDefault();
selectStep(Math.min(currentLesson.steps.length - 1, currentStepIndex + 1));
}
}
export function cleanup() {
if (autoSaveTimer) clearInterval(autoSaveTimer);
if (vditorInstance) vditorInstance.destroy();
document.removeEventListener('keydown', handleKeyboard);
}

136
frontend/src/views/login.js Normal file
View File

@@ -0,0 +1,136 @@
import { login, register } from '../components/auth.js';
import { router } from '../router.js';
export async function render() {
return `
<div class="login-page">
<div class="login-container">
<h2>С возвращением</h2>
<div class="tabs">
<button class="tab-btn active" data-tab="login">Вход</button>
<button class="tab-btn" data-tab="register">Регистрация</button>
</div>
<div class="tab-content">
<div id="login-form" class="form-container active">
<form id="loginForm">
<div class="form-group">
<label for="login-email">Эл. почта</label>
<input type="email" id="login-email" required>
</div>
<div class="form-group">
<label for="login-password">Пароль</label>
<input type="password" id="login-password" required>
</div>
<button type="submit" class="btn btn-primary">Войти</button>
</form>
<div id="login-message" class="message"></div>
</div>
<div id="register-form" class="form-container">
<form id="registerForm">
<div class="form-group">
<label for="register-email">Эл. почта</label>
<input type="email" id="register-email" required>
</div>
<div class="form-group">
<label for="register-password">Пароль (минимум 8 символов)</label>
<input type="password" id="register-password" minlength="8" required>
</div>
<div class="form-group">
<label for="register-confirm">Подтверждение пароля</label>
<input type="password" id="register-confirm" minlength="8" required>
</div>
<button type="submit" class="btn btn-primary">Зарегистрироваться</button>
</form>
</div>
</div>
<div id="register-message" class="message global-message"></div>
<div class="login-footer">
<a href="/" data-link>← Back to Home</a>
</div>
</div>
</div>
`;
}
export function afterRender() {
// Tab switching
document.querySelectorAll('.tab-btn').forEach(button => {
button.addEventListener('click', () => {
const tab = button.dataset.tab;
// Update active tab button
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Show active form
document.querySelectorAll('.form-container').forEach(form => form.classList.remove('active'));
document.getElementById(`${tab}-form`).classList.add('active');
// Clear messages when switching tabs (except global registration message)
document.getElementById('login-message').textContent = '';
// Don't clear register-message if it contains success message
const registerMsg = document.getElementById('register-message');
if (!registerMsg.textContent.includes('✅')) {
registerMsg.textContent = '';
}
});
});
// Login form submission
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value;
const messageEl = document.getElementById('login-message');
messageEl.textContent = 'Вход в систему...';
messageEl.className = 'message info';
const result = await login(email, password);
if (result.success) {
messageEl.textContent = '✅ Вход выполнен успешно! Перенаправление...';
messageEl.className = 'message success';
setTimeout(() => router.navigate('/'), 1000);
} else {
messageEl.textContent = `❌ Ошибка: ${result.error}`;
messageEl.className = 'message error';
}
});
// Register form submission
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('register-email').value;
const password = document.getElementById('register-password').value;
const confirmPassword = document.getElementById('register-confirm').value;
const messageEl = document.getElementById('register-message');
if (password !== confirmPassword) {
messageEl.textContent = '❌ Пароли не совпадают';
messageEl.className = 'message error';
return;
}
messageEl.textContent = 'Создание аккаунта...';
messageEl.className = 'message info';
const result = await register(email, password);
if (result.success) {
messageEl.textContent = '✅ Регистрация успешна! Теперь вы можете войти в систему.';
messageEl.className = 'message success global-message';
// Switch to login tab
document.querySelector('.tab-btn[data-tab="login"]').click();
document.getElementById('login-email').value = email;
document.getElementById('login-password').value = '';
// Clear login message
document.getElementById('login-message').textContent = '';
} else {
messageEl.textContent = `❌ Ошибка: ${result.error}`;
messageEl.className = 'message error global-message';
}
});
}

View File

@@ -0,0 +1,120 @@
import { coursesAPI } from '../api/courses.js';
import { currentUser } from '../components/auth.js';
export async function render() {
if (!currentUser) {
return `
<div class="auth-required">
<h2>Authentication Required</h2>
<p>Please <a href="/login" data-link>login</a> to view your progress.</p>
</div>
`;
}
// Fetch progress for all courses
const allCourses = await coursesAPI.getAll();
const progressPromises = allCourses.map(async (course) => {
const progress = await coursesAPI.getProgress(course.id);
return { course, progress };
});
const courseProgress = await Promise.all(progressPromises);
const coursesWithProgress = courseProgress.filter(cp => cp.progress);
const coursesWithoutProgress = courseProgress.filter(cp => !cp.progress);
return `
<div class="progress-page">
<h1>My Learning Progress</h1>
${coursesWithProgress.length === 0 ? `
<div class="empty-state">
<p>You haven't started any courses yet.</p>
<a href="/courses" class="btn" data-link>Browse Courses</a>
</div>
` : `
<div class="progress-list">
${coursesWithProgress.map(({ course, progress }) => {
const percentage = progress.percentage ||
(progress.completed_lessons / progress.total_lessons * 100);
return `
<div class="progress-item" data-course-id="${course.id}">
<div class="progress-header">
<h3>${course.title}</h3>
<span class="progress-percentage">${percentage.toFixed(1)}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${percentage}%"></div>
</div>
<div class="progress-details">
<span>${progress.completed_lessons} of ${progress.total_lessons} lessons completed</span>
<button class="btn-update-progress" data-course-id="${course.id}">
Update Progress
</button>
</div>
</div>
`;
}).join('')}
</div>
`}
${coursesWithoutProgress.length > 0 ? `
<div class="available-courses">
<h2>Available Courses</h2>
<div class="courses-grid">
${coursesWithoutProgress.slice(0, 3).map(({ course }) => `
<div class="course-card small">
<h4>${course.title}</h4>
<span class="course-level ${course.level}">${course.level}</span>
<button class="btn-start-course" data-course-id="${course.id}">
Start Learning
</button>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
}
export async function afterRender() {
if (!currentUser) return;
// Add event listeners for update progress buttons
document.querySelectorAll('.btn-update-progress').forEach(button => {
button.addEventListener('click', async (e) => {
const courseId = parseInt(e.target.dataset.courseId);
const completed = prompt('Enter number of completed lessons:');
const total = prompt('Enter total number of lessons:');
if (completed && total) {
try {
await coursesAPI.updateProgress(courseId, parseInt(completed), parseInt(total));
alert('Progress updated successfully!');
location.reload();
} catch (error) {
alert('Failed to update progress: ' + error.message);
}
}
});
});
// Add event listeners for start course buttons
document.querySelectorAll('.btn-start-course').forEach(button => {
button.addEventListener('click', async (e) => {
const courseId = parseInt(e.target.dataset.courseId);
const completed = 0;
const total = prompt('Enter total number of lessons for this course:');
if (total) {
try {
await coursesAPI.updateProgress(courseId, completed, parseInt(total));
alert('Course started! You can now track your progress.');
location.reload();
} catch (error) {
alert('Failed to start course: ' + error.message);
}
}
});
});
}