dragonflight/services/web-ui/public/upload.html

910 lines
31 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wild Dragon - Web Uploader</title>
<link rel="stylesheet" href="/css/common.css">
<style>
.upload-container {
display: flex;
flex-direction: column;
height: calc(100vh - 110px);
overflow: hidden;
}
.upload-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.upload-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.upload-header h1 {
margin: 0;
}
.upload-controls {
display: flex;
gap: var(--spacing-md);
}
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-lg);
min-height: 300px;
padding: var(--spacing-xl);
background-color: var(--color-bg-tertiary);
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-normal);
}
.dropzone:hover {
border-color: var(--color-accent-primary);
background-color: var(--color-bg-hover);
}
.dropzone.dragover {
border-color: var(--color-accent-primary);
background-color: rgba(233, 69, 96, 0.1);
}
.dropzone-icon {
font-size: 3rem;
opacity: 0.6;
}
.dropzone-text {
text-align: center;
}
.dropzone-text h3 {
margin-bottom: var(--spacing-sm);
color: var(--color-text-primary);
}
.dropzone-text p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.upload-config {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.config-section {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.config-section-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.upload-queue {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
padding: var(--spacing-lg);
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.queue-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.queue-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
}
.queue-clear {
font-size: 0.85rem;
color: var(--color-accent-primary);
cursor: pointer;
transition: color var(--transition-fast);
}
.queue-clear:hover {
color: var(--color-accent-light);
}
.queue-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.queue-item {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--spacing-md);
padding: var(--spacing-md);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
align-items: center;
}
.queue-item-info {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
min-width: 0;
}
.queue-item-name {
font-weight: 600;
color: var(--color-text-primary);
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-item-meta {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--color-text-tertiary);
margin-bottom: var(--spacing-xs);
}
.queue-item-progress {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
width: 100%;
}
.progress-bar {
width: 100%;
height: 6px;
background-color: var(--color-bg-primary);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-accent-primary), var(--color-accent-light));
border-radius: 3px;
width: 0%;
transition: width var(--transition-fast);
}
.progress-text {
font-size: 0.8rem;
color: var(--color-text-tertiary);
display: flex;
justify-content: space-between;
}
.queue-item-actions {
display: flex;
gap: var(--spacing-sm);
}
.queue-item-status {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
white-space: nowrap;
}
.queue-item-status.waiting {
color: var(--color-text-tertiary);
}
.queue-item-status.uploading {
color: var(--color-info);
}
.queue-item-status.processing {
color: var(--color-warning);
}
.queue-item-status.complete {
color: var(--color-success);
}
.queue-item-status.error {
color: var(--color-danger);
}
.btn-cancel {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 0.8rem;
background-color: var(--color-status-error);
color: white;
}
.btn-cancel:hover {
background-color: #ef5555;
}
.queue-empty {
display: flex;
align-items: center;
justify-content: center;
height: 150px;
color: var(--color-text-tertiary);
text-align: center;
}
.overall-progress {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background-color: var(--color-bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.overall-progress-label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-primary);
}
.overall-progress-bar {
width: 100%;
height: 8px;
background-color: var(--color-bg-primary);
border-radius: 4px;
overflow: hidden;
}
.overall-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-accent-primary), var(--color-accent-light));
width: 0%;
transition: width var(--transition-fast);
}
.hidden-file-input {
display: none;
}
.create-project-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
padding: var(--spacing-lg);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.create-project-form-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
}
@media (max-width: 768px) {
.upload-config {
grid-template-columns: 1fr;
}
.upload-container {
height: calc(100vh - 100px);
}
}
</style>
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="header">
<div class="header-logo">
<div class="header-logo-icon">D</div>
<span>WILD DRAGON</span>
</div>
<nav class="header-nav">
<div class="nav-item" data-page="assets">Assets</div>
<div class="nav-item" data-page="capture">Capture</div>
<div class="nav-item active" data-page="upload">Upload</div>
<div class="nav-item" data-page="recorders">Recorders</div>
</nav>
</header>
<!-- Main Content -->
<div class="main-content">
<div class="content-area">
<div class="upload-container">
<div class="upload-content">
<!-- Header -->
<div class="upload-header">
<h1>Web Uploader</h1>
<div class="upload-controls">
<button class="btn btn-secondary" id="backBtn" onclick="navigateTo('assets')">← Back</button>
</div>
</div>
<!-- Dropzone -->
<div class="dropzone" id="dropzone">
<div class="dropzone-icon">📤</div>
<div class="dropzone-text">
<h3>Drop files here or click to browse</h3>
<p>Supported formats: MOV, MP4, MXF, AVI, MKV, TS, M2T</p>
</div>
</div>
<!-- Configuration -->
<div class="upload-config">
<div class="config-section">
<div class="config-section-title">Project</div>
<select id="projectSelect" class="form-select"></select>
<div id="createProjectSection" style="display: none;">
<button class="btn btn-secondary" style="width: 100%;" onclick="toggleCreateProject()">+ Create New Project</button>
<div class="create-project-form" id="createProjectForm" style="display: none;">
<div class="create-project-form-title">New Project</div>
<input type="text" id="newProjectName" class="form-input" placeholder="Project name">
<div style="display: flex; gap: var(--spacing-sm);">
<button class="btn btn-primary btn-sm" style="flex: 1;" onclick="createNewProject()">Create</button>
<button class="btn btn-secondary btn-sm" style="flex: 1;" onclick="toggleCreateProject()">Cancel</button>
</div>
</div>
</div>
</div>
<div class="config-section">
<div class="config-section-title">Bin</div>
<select id="binSelect" class="form-select"></select>
</div>
</div>
<!-- Overall Progress -->
<div class="overall-progress" id="overallProgressContainer" style="display: none;">
<div class="overall-progress-label">
<span>Overall Progress</span>
<span id="overallProgressPercent">0%</span>
</div>
<div class="overall-progress-bar">
<div class="overall-progress-fill" id="overallProgressFill"></div>
</div>
</div>
<!-- Upload Queue -->
<div class="upload-queue">
<div class="queue-header">
<div class="queue-title">Upload Queue</div>
<div class="queue-clear" id="queueClear" style="display: none;" onclick="clearCompleted()">Clear Completed</div>
</div>
<div class="queue-list" id="queueList">
<div class="queue-empty">No files selected</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Status Bar -->
<footer class="status-bar">
<div class="status-item">
<span class="status-indicator" id="statusIndicator"></span>
<span id="statusText">Ready to upload</span>
</div>
<div class="status-item" id="uploadStatsItem" style="display: none;">
<span id="uploadStats">0 files uploaded</span>
</div>
</footer>
</div>
<!-- Hidden file input -->
<input type="file" id="fileInput" class="hidden-file-input" multiple accept=".mov,.mp4,.mxf,.avi,.mkv,.ts,.m2t">
<script src="/js/api.js"></script>
<script>
// ============================================================
// STATE MANAGEMENT
// ============================================================
let uploadState = {
projects: [],
bins: [],
selectedProject: null,
selectedBin: null,
uploadQueue: [],
activeUploads: 0,
completedUploads: 0,
};
// ============================================================
// INITIALIZATION
// ============================================================
document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners();
await loadProjects();
updateStatusBar();
});
function setupEventListeners() {
// Navigation
document.querySelectorAll('[data-page]').forEach(el => {
el.addEventListener('click', (e) => navigateTo(e.target.dataset.page));
});
// Dropzone
const dropzone = document.getElementById('dropzone');
dropzone.addEventListener('click', () => document.getElementById('fileInput').click());
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', () => {
dropzone.classList.remove('dragover');
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
handleFileSelection(e.dataTransfer.files);
});
// File input
document.getElementById('fileInput').addEventListener('change', (e) => {
handleFileSelection(e.target.files);
});
// Project selection
document.getElementById('projectSelect').addEventListener('change', (e) => {
uploadState.selectedProject = e.target.value;
loadBins();
});
// Bin selection
document.getElementById('binSelect').addEventListener('change', (e) => {
uploadState.selectedBin = e.target.value;
});
}
// ============================================================
// PROJECT & BIN MANAGEMENT
// ============================================================
async function loadProjects() {
try {
const result = await getProjects();
if (result.success) {
uploadState.projects = result.data;
renderProjects();
if (uploadState.projects.length > 0) {
uploadState.selectedProject = uploadState.projects[0].id;
document.getElementById('projectSelect').value = uploadState.selectedProject;
await loadBins();
} else {
document.getElementById('createProjectSection').style.display = 'block';
}
}
} catch (error) {
console.error('Failed to load projects:', error);
}
}
function renderProjects() {
const select = document.getElementById('projectSelect');
select.innerHTML = '';
uploadState.projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.name;
select.appendChild(option);
});
}
async function loadBins() {
if (!uploadState.selectedProject) return;
try {
const result = await getBins(uploadState.selectedProject);
if (result.success) {
uploadState.bins = result.data;
renderBins();
}
} catch (error) {
console.error('Failed to load bins:', error);
}
}
function renderBins() {
const select = document.getElementById('binSelect');
select.innerHTML = '';
uploadState.bins.forEach(bin => {
const option = document.createElement('option');
option.value = bin.id;
option.textContent = bin.name;
select.appendChild(option);
});
if (uploadState.bins.length > 0) {
uploadState.selectedBin = uploadState.bins[0].id;
document.getElementById('binSelect').value = uploadState.selectedBin;
}
}
function toggleCreateProject() {
const form = document.getElementById('createProjectForm');
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
}
async function createNewProject() {
const name = document.getElementById('newProjectName').value.trim();
if (!name) {
alert('Please enter a project name');
return;
}
try {
const result = await createProject(name);
if (result.success) {
await loadProjects();
toggleCreateProject();
document.getElementById('newProjectName').value = '';
updateStatusBar('Project created');
} else {
alert('Failed to create project');
}
} catch (error) {
console.error('Failed to create project:', error);
alert('Error creating project');
}
}
// ============================================================
// FILE HANDLING
// ============================================================
function handleFileSelection(files) {
Array.from(files).forEach(file => {
if (isValidVideoFile(file)) {
addToQueue(file);
}
});
}
function isValidVideoFile(file) {
const validTypes = ['.mov', '.mp4', '.mxf', '.avi', '.mkv', '.ts', '.m2t'];
const ext = '.' + file.name.split('.').pop().toLowerCase();
return validTypes.includes(ext);
}
function addToQueue(file) {
const id = 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
const queueItem = {
id,
file,
status: 'waiting',
progress: 0,
uploadId: null,
uploadedBytes: 0,
};
uploadState.uploadQueue.push(queueItem);
renderQueue();
// Auto-start upload if not at max concurrent
if (uploadState.activeUploads < 3) {
uploadNext();
}
}
function renderQueue() {
const list = document.getElementById('queueList');
const clear = document.getElementById('queueClear');
if (uploadState.uploadQueue.length === 0) {
list.innerHTML = '<div class="queue-empty">No files selected</div>';
clear.style.display = 'none';
return;
}
clear.style.display = uploadState.uploadQueue.some(u => u.status === 'complete' || u.status === 'error') ? 'block' : 'none';
list.innerHTML = '';
uploadState.uploadQueue.forEach(item => {
const el = createQueueItemElement(item);
list.appendChild(el);
});
}
function createQueueItemElement(item) {
const el = document.createElement('div');
el.className = 'queue-item';
el.id = `queue-${item.id}`;
const isComplete = item.status === 'complete';
const isError = item.status === 'error';
const isProcessing = item.status === 'processing';
el.innerHTML = `
<div class="queue-item-info">
<div class="queue-item-name">${item.file.name}</div>
<div class="queue-item-meta">
<span>${formatFileSize(item.file.size)}</span>
<span id="progress-${item.id}">${item.progress}%</span>
</div>
${item.status !== 'waiting' ? `
<div class="queue-item-progress">
<div class="progress-bar">
<div class="progress-fill" id="fill-${item.id}" style="width: ${item.progress}%"></div>
</div>
</div>
` : ''}
</div>
<div class="queue-item-actions">
<div class="queue-item-status ${item.status}">
${item.status === 'waiting' ? 'Waiting' :
item.status === 'uploading' ? 'Uploading' :
item.status === 'processing' ? 'Processing' :
item.status === 'complete' ? 'Complete' :
item.status === 'error' ? 'Error' : item.status}
</div>
${item.status === 'waiting' || item.status === 'uploading' ? `
<button class="btn btn-cancel btn-sm" onclick="cancelUpload('${item.id}')">Cancel</button>
` : ''}
</div>
`;
return el;
}
// ============================================================
// UPLOAD LOGIC
// ============================================================
async function uploadNext() {
const item = uploadState.uploadQueue.find(u => u.status === 'waiting');
if (!item) return;
uploadState.activeUploads++;
item.status = 'uploading';
updateQueueItem(item);
updateOverallProgress();
try {
const file = item.file;
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size < 50 * 1024 * 1024) {
// Simple upload for small files
await simpleUploadFile(item);
} else {
// Multipart upload for large files
await multipartUploadFile(item, CHUNK_SIZE);
}
item.status = 'processing';
updateQueueItem(item);
updateOverallProgress();
// Simulate processing completion
setTimeout(() => {
item.status = 'complete';
item.progress = 100;
uploadState.completedUploads++;
updateQueueItem(item);
updateOverallProgress();
uploadState.activeUploads--;
uploadNext();
updateStatusBar();
}, 2000);
} catch (error) {
console.error('Upload error:', error);
item.status = 'error';
updateQueueItem(item);
updateOverallProgress();
uploadState.activeUploads--;
uploadNext();
}
}
async function simpleUploadFile(item) {
const formData = new FormData();
formData.append('file', item.file);
formData.append('project_id', uploadState.selectedProject);
formData.append('bin_id', uploadState.selectedBin);
const result = await simpleUpload(formData);
if (!result.success) {
throw new Error(result.error);
}
item.progress = 100;
updateQueueItem(item);
}
async function multipartUploadFile(item, chunkSize) {
const file = item.file;
const fileName = file.name;
// Initialize upload
const initResult = await initUpload({
filename: fileName,
content_type: file.type,
project_id: uploadState.selectedProject,
bin_id: uploadState.selectedBin,
});
if (!initResult.success) {
throw new Error('Failed to initialize upload');
}
item.uploadId = initResult.data.upload_id;
const parts = [];
const totalChunks = Math.ceil(file.size / chunkSize);
// Upload chunks
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('uploadId', item.uploadId);
formData.append('partNumber', i + 1);
formData.append('key', fileName);
const chunkResult = await uploadPart(formData);
if (!chunkResult.success) {
throw new Error('Failed to upload chunk ' + (i + 1));
}
parts.push({
part_number: i + 1,
etag: chunkResult.data.etag,
});
item.uploadedBytes = end;
item.progress = Math.round((end / file.size) * 100);
updateQueueItem(item);
}
// Complete upload
const completeResult = await completeUpload({
upload_id: item.uploadId,
parts,
});
if (!completeResult.success) {
throw new Error('Failed to complete upload');
}
item.progress = 100;
updateQueueItem(item);
}
function updateQueueItem(item) {
const el = document.getElementById(`queue-${item.id}`);
if (el) {
el.parentNode.replaceChild(createQueueItemElement(item), el);
}
}
function cancelUpload(id) {
const item = uploadState.uploadQueue.find(u => u.id === id);
if (!item) return;
if (item.uploadId) {
abortUpload({ upload_id: item.uploadId }).catch(console.error);
}
uploadState.uploadQueue = uploadState.uploadQueue.filter(u => u.id !== id);
renderQueue();
updateOverallProgress();
uploadState.activeUploads--;
uploadNext();
}
function clearCompleted() {
uploadState.uploadQueue = uploadState.uploadQueue.filter(u => u.status !== 'complete' && u.status !== 'error');
renderQueue();
updateOverallProgress();
}
// ============================================================
// UI UPDATES
// ============================================================
function updateOverallProgress() {
const container = document.getElementById('overallProgressContainer');
const queue = uploadState.uploadQueue;
if (queue.length === 0) {
container.style.display = 'none';
return;
}
const totalProgress = queue.reduce((sum, u) => sum + u.progress, 0) / queue.length;
const percent = Math.round(totalProgress);
container.style.display = 'flex';
document.getElementById('overallProgressPercent').textContent = percent + '%';
document.getElementById('overallProgressFill').style.width = percent + '%';
}
function updateStatusBar() {
const indicator = document.getElementById('statusIndicator');
const text = document.getElementById('statusText');
const stats = document.getElementById('uploadStatsItem');
indicator.className = 'status-indicator';
if (uploadState.activeUploads > 0) {
text.textContent = `Uploading ${uploadState.activeUploads} file(s)...`;
} else if (uploadState.completedUploads > 0) {
text.textContent = `${uploadState.completedUploads} file(s) uploaded`;
} else {
text.textContent = 'Ready to upload';
}
if (uploadState.completedUploads > 0) {
stats.style.display = 'flex';
document.getElementById('uploadStats').textContent = uploadState.completedUploads + ' file(s) uploaded';
}
}
// ============================================================
// NAVIGATION
// ============================================================
function navigateTo(page) {
if (page === 'assets') {
window.location.href = '/index.html';
} else if (page === 'capture') {
window.location.href = '/capture.html';
} else if (page === 'recorders') {
window.location.href = '/recorders.html';
}
}
</script>
</body>
</html>