transcription/backend/static/app.js
2026-05-29 12:52:51 +03:00

412 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Frontend application for Transcription Service
*/
class TranscriptionApp {
constructor() {
this.ws = null;
this.tasks = new Map();
this.currentFile = null;
this.init();
}
init() {
this.connectWebSocket();
this.setupUpload();
}
// ===== WebSocket =====
connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.showToast('Подключено к серверу', 'success');
this.requestTasks();
this.requestTree();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected, reconnecting in 3s...');
setTimeout(() => this.connectWebSocket(), 3000);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
sendWS(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
requestTasks() {
this.sendWS({ action: 'get_tasks' });
}
requestTree() {
this.sendWS({ action: 'get_tree' });
}
handleWebSocketMessage(data) {
if (data.type === 'tasks_list') {
this.updateTasks(data.tasks);
} else if (data.type === 'file_tree') {
this.renderFileTree(data.tree);
} else if (data.task_id) {
// Прогресс обработки
this.updateTaskProgress(data);
}
}
// ===== Upload =====
setupUpload() {
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const browseLink = document.querySelector('.browse-link');
// Click on browse link (stop propagation to avoid double click)
browseLink.addEventListener('click', (e) => {
e.stopPropagation();
fileInput.click();
});
// Click on drop zone (but not on the file input itself)
dropZone.addEventListener('click', (e) => {
if (e.target === fileInput) return;
fileInput.click();
});
// File input change - reset value after handling
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
if (files.length) {
this.handleFiles(files);
}
// Reset so the same file can be selected again
fileInput.value = '';
});
// Drag & drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
this.handleFiles(e.dataTransfer.files);
});
}
async handleFiles(files) {
if (!files.length) return;
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}
try {
this.showToast(`Загрузка ${files.length} файл(а)...`, 'info');
const response = await fetch('/upload-batch', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.error) {
this.showToast(`Ошибка: ${result.error}`, 'error');
} else {
this.showToast(`Загружено ${result.uploaded} файл(а). Начинается обработка...`, 'success');
result.tasks.forEach(task => {
this.tasks.set(task.task_id, task);
});
this.renderTasks();
}
} catch (error) {
this.showToast(`Ошибка загрузки: ${error.message}`, 'error');
}
}
// ===== Tasks / Progress =====
updateTasks(tasks) {
tasks.forEach(task => {
this.tasks.set(task.file + '_' + task.started, task);
});
this.renderTasks();
}
updateTaskProgress(data) {
const existing = Array.from(this.tasks.values()).find(t => t.task_id === data.task_id);
if (existing) {
Object.assign(existing, data);
} else {
this.tasks.set(data.task_id, data);
}
this.renderTasks();
if (data.status === 'completed') {
this.showToast(`Готово: ${data.message}`, 'success');
this.requestTree();
} else if (data.status === 'error') {
this.showToast(`Ошибка: ${data.message}`, 'error');
}
}
renderTasks() {
const container = document.getElementById('tasksList');
const tasks = Array.from(this.tasks.values());
if (tasks.length === 0) {
container.innerHTML = '<p class="empty-state">Нет активных задач</p>';
return;
}
container.innerHTML = tasks.map(task => this.renderTaskItem(task)).join('');
}
renderTaskItem(task) {
const progress = task.progress || 0;
const statusClass = task.status === 'completed' ? 'success' :
task.status === 'error' ? 'error' :
task.status === 'processing' ? 'processing' : 'queued';
return `
<div class="task-item ${statusClass}">
<div class="task-header">
<span class="task-filename">${this.escapeHtml(task.file || '')}</span>
<span class="task-status-badge ${statusClass}">${this.getStatusLabel(task.status)}</span>
</div>
<div class="task-progress-bar">
<div class="task-progress-fill" style="width: ${progress}%"></div>
</div>
<div class="task-message">${this.escapeHtml(task.message || '')}</div>
</div>
`;
}
getStatusLabel(status) {
const labels = {
'queued': 'В очереди',
'processing': 'Обработка',
'completed': 'Готово',
'error': 'Ошибка',
};
return labels[status] || status;
}
// ===== File Tree =====
renderFileTree(tree) {
const container = document.getElementById('fileTree');
if (!tree || tree.length === 0) {
container.innerHTML = '<p class="empty-state">Нет обработанных файлов</p>';
return;
}
container.innerHTML = tree.map(folder => this.renderFolder(folder)).join('');
}
renderFolder(folder) {
const files = folder.files.map(file => {
const isMd = file.ext === '.md';
const isDocx = file.ext === '.docx';
const icon = isMd ? '📝' : isDocx ? '📄' : '📎';
const clickable = isMd ? 'clickable' : '';
return `
<div class="file-item ${clickable}" data-path="${this.escapeHtml(file.path)}" data-ext="${file.ext}">
<span class="file-icon">${icon}</span>
<span class="file-name">${this.escapeHtml(file.name)}</span>
<span class="file-size">${this.formatBytes(file.size)}</span>
</div>
`;
}).join('');
return `
<div class="folder-item" data-folder="${this.escapeHtml(folder.name)}">
<div class="folder-header">
<span class="folder-toggle">▼</span>
<span class="folder-icon">📁</span>
<span class="folder-name">${this.escapeHtml(folder.name)}</span>
<span class="folder-date">${this.formatDate(folder.created)}</span>
<span class="folder-delete" title="Удалить папку">🗑️</span>
</div>
<div class="folder-files">
${files}
</div>
</div>
`;
}
setupFileTreeEvents() {
const container = document.getElementById('fileTree');
container.addEventListener('click', (e) => {
// Toggle folder
const folderHeader = e.target.closest('.folder-header');
if (folderHeader && !e.target.closest('.folder-delete')) {
const folder = folderHeader.closest('.folder-item');
const files = folder.querySelector('.folder-files');
const toggle = folderHeader.querySelector('.folder-toggle');
if (files.style.display === 'none') {
files.style.display = 'block';
toggle.textContent = '▼';
} else {
files.style.display = 'none';
toggle.textContent = '▶';
}
}
// Delete folder
const deleteBtn = e.target.closest('.folder-delete');
if (deleteBtn) {
const folderItem = deleteBtn.closest('.folder-item');
const folderName = folderItem.dataset.folder;
this.confirmDeleteFolder(folderName, folderItem);
return;
}
// Open file
const fileItem = e.target.closest('.file-item.clickable');
if (fileItem) {
const path = fileItem.dataset.path;
const ext = fileItem.dataset.ext;
this.loadFileContent(path, ext);
}
});
}
confirmDeleteFolder(folderName, folderElement) {
if (confirm(`Удалить папку "${folderName}" со всеми файлами?\n\nЭто действие нельзя отменить.`)) {
this.deleteFolder(folderName, folderElement);
}
}
async deleteFolder(folderName, folderElement) {
try {
const response = await fetch(`/api/folders/${encodeURIComponent(folderName)}`, {
method: 'DELETE',
});
const result = await response.json();
if (result.error) {
this.showToast(`Ошибка удаления: ${result.error}`, 'error');
} else {
this.showToast(`Папка "${folderName}" удалена`, 'success');
folderElement.remove();
// Refresh tree if empty
const container = document.getElementById('fileTree');
if (!container.querySelector('.folder-item')) {
container.innerHTML = '<p class="empty-state">Нет обработанных файлов</p>';
}
}
} catch (error) {
this.showToast(`Ошибка удаления: ${error.message}`, 'error');
}
}
// ===== Viewer =====
async loadFileContent(path, ext) {
const viewer = document.getElementById('viewer');
if (ext === '.md') {
try {
const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
const result = await response.json();
if (result.error) {
viewer.innerHTML = `<div class="error">${this.escapeHtml(result.error)}</div>`;
return;
}
// Render markdown
const html = marked.parse(result.content);
viewer.innerHTML = `
<div class="md-content">
<div class="md-header">
<span class="md-title">${this.escapeHtml(path)}</span>
<a href="/api/files/download?path=${encodeURIComponent(path)}"
class="btn-download" download>⬇️ Скачать</a>
</div>
<div class="md-body">${html}</div>
</div>
`;
this.currentFile = path;
} catch (error) {
viewer.innerHTML = `<div class="error">Ошибка загрузки: ${this.escapeHtml(error.message)}</div>`;
}
} else if (ext === '.docx') {
viewer.innerHTML = `
<div class="file-preview">
<p>Для просмотра DOCX скачайте файл:</p>
<a href="/api/files/download?path=${encodeURIComponent(path)}"
class="btn-download" download>⬇️ Скачать ${this.escapeHtml(path)}</a>
</div>
`;
}
}
// ===== Utilities =====
showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-hide');
setTimeout(() => toast.remove(), 300);
}, 4000);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
formatDate(isoDate) {
const date = new Date(isoDate);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
const app = new TranscriptionApp();
app.setupFileTreeEvents();
});