transcription/backend/static/app.js

362 lines
12 KiB
JavaScript
Raw Normal View History

/**
* 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 to browse
browseLink.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('click', (e) => {
if (e.target === dropZone || e.target.closest('.drop-zone-content')) {
fileInput.click();
}
});
// File input change
fileInput.addEventListener('change', (e) => {
this.handleFiles(e.target.files);
});
// 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">
<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>
</div>
<div class="folder-files">
${files}
</div>
</div>
`;
}
setupFileTreeEvents() {
const container = document.getElementById('fileTree');
container.addEventListener('click', (e) => {
const folderHeader = e.target.closest('.folder-header');
if (folderHeader) {
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 = '▶';
}
}
const fileItem = e.target.closest('.file-item.clickable');
if (fileItem) {
const path = fileItem.dataset.path;
const ext = fileItem.dataset.ext;
this.loadFileContent(path, ext);
}
});
}
// ===== 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();
});