362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
/**
|
||
* 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();
|
||
});
|