Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
463 lines
18 KiB
HTML
463 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Ingest — Wild Dragon</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="css/common.css">
|
|
<style>
|
|
.ingest-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-5);
|
|
max-width: 860px;
|
|
}
|
|
|
|
/* Context bar */
|
|
.context-bar {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: var(--sp-4);
|
|
}
|
|
|
|
.context-bar .form-group { flex: 1; }
|
|
|
|
/* Drop zone */
|
|
.drop-zone {
|
|
border: 1px dashed var(--border);
|
|
border-radius: var(--r-md);
|
|
background: var(--bg-deep);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: var(--sp-5) var(--sp-6);
|
|
gap: var(--sp-3);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
transition: border-color var(--t-fast), background var(--t-fast);
|
|
min-height: 88px;
|
|
position: relative;
|
|
}
|
|
|
|
.drop-zone:hover,
|
|
.drop-zone.drag-over {
|
|
border-color: var(--accent);
|
|
background: var(--accent-subtle);
|
|
}
|
|
|
|
.drop-zone input[type="file"] {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.drop-zone-icon { color: var(--text-tertiary); }
|
|
.drop-zone-icon svg { width: 22px; height: 22px; }
|
|
|
|
.drop-zone-primary {
|
|
font-size: var(--text-md);
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.drop-zone-secondary {
|
|
font-size: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.drop-zone.drag-over .drop-zone-icon { color: var(--accent); }
|
|
.drop-zone.drag-over .drop-zone-primary { color: var(--accent); }
|
|
|
|
/* Upload queue */
|
|
.queue-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.queue-title {
|
|
font-size: var(--text-sm);
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.queue-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-2);
|
|
}
|
|
|
|
.queue-item {
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-lg);
|
|
padding: var(--sp-3) var(--sp-4);
|
|
display: flex;
|
|
gap: var(--sp-4);
|
|
align-items: center;
|
|
}
|
|
|
|
.queue-item-icon { color: var(--text-tertiary); flex-shrink: 0; }
|
|
.queue-item-icon svg { width: 18px; height: 18px; }
|
|
|
|
.queue-item-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
|
|
|
.queue-item-name {
|
|
font-size: var(--text-sm);
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.queue-item-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-3);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.queue-item-progress { width: 100%; }
|
|
|
|
.queue-item-status { flex-shrink: 0; }
|
|
|
|
.queue-item.status-done { border-color: oklch(68% 0.18 148 / 0.25); }
|
|
.queue-item.status-error { border-color: oklch(62% 0.22 25 / 0.25); }
|
|
.queue-item.status-active { border-color: var(--accent-border); }
|
|
|
|
/* Overall progress */
|
|
.overall-progress {
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-lg);
|
|
padding: var(--sp-4);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-4);
|
|
}
|
|
.overall-progress-label { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; }
|
|
.overall-progress-bar { flex: 1; }
|
|
.overall-progress-pct { font-size: var(--text-sm); font-variant-numeric: tabular-nums; color: var(--accent); font-weight: 500; white-space: nowrap; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<nav class="sidebar" aria-label="Main navigation">
|
|
<div class="sidebar-brand">
|
|
<div class="sidebar-brand-mark">
|
|
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg>
|
|
</div>
|
|
<span class="sidebar-brand-name">Wild Dragon</span>
|
|
</div>
|
|
<nav class="sidebar-nav">
|
|
<a href="index.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
|
Library
|
|
</a>
|
|
<a href="upload.html" class="nav-item active">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
|
|
Ingest
|
|
</a>
|
|
<a href="recorders.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
|
|
Recorders
|
|
</a>
|
|
<a href="capture.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
|
|
Capture
|
|
</a>
|
|
<a href="jobs.html" class="nav-item">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
|
|
Jobs
|
|
</a>
|
|
<a href="#" class="nav-item" target="_blank" onclick="window.open(location.protocol + '//' + location.hostname + ':47435/', '_blank'); return false;" rel="noopener">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
|
|
Editor
|
|
</a>
|
|
</nav>
|
|
</nav>
|
|
|
|
<div class="main">
|
|
<header class="topbar">
|
|
<div class="topbar-left">
|
|
<span class="page-title">Ingest</span>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<button class="btn btn-ghost btn-sm" id="clearDoneBtn" style="display:none;">Clear completed</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="page-content">
|
|
<div class="ingest-content">
|
|
<!-- Project + bin selectors -->
|
|
<div class="context-bar">
|
|
<div class="form-group">
|
|
<label class="form-label" for="projectSel">Project</label>
|
|
<select id="projectSel">
|
|
<option value="">Select project…</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="binSel">Bin</label>
|
|
<select id="binSel">
|
|
<option value="">No bin (project root)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Drop zone -->
|
|
<div class="drop-zone" id="dropZone">
|
|
<input type="file" id="fileInput" multiple accept="video/*,audio/*,image/*" aria-label="Select files to upload">
|
|
<div class="drop-zone-icon">
|
|
<svg viewBox="0 0 36 36" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M18 26V10M11 17l7-7 7 7"/>
|
|
<path d="M4 28h28"/>
|
|
<rect x="2" y="4" width="32" height="26" rx="2" stroke-opacity="0.25"/>
|
|
</svg>
|
|
</div>
|
|
<div class="drop-zone-primary">Drop files here or click to browse</div>
|
|
<div class="drop-zone-secondary">Video, audio, and image files — up to 5 GB each</div>
|
|
</div>
|
|
|
|
<!-- Overall progress (hidden until uploading) -->
|
|
<div class="overall-progress" id="overallProgress" style="display:none;">
|
|
<span class="overall-progress-label" id="overallLabel">Uploading…</span>
|
|
<div class="overall-progress-bar progress-bar">
|
|
<div class="progress-fill" id="overallFill" style="width:0%"></div>
|
|
</div>
|
|
<span class="overall-progress-pct" id="overallPct">0%</span>
|
|
</div>
|
|
|
|
<!-- Queue -->
|
|
<div id="queueSection" style="display:none;">
|
|
<div class="queue-header" style="margin-bottom:var(--sp-3);">
|
|
<span class="queue-title">Queue</span>
|
|
<span class="text-xs text-tertiary" id="queueSummary"></span>
|
|
</div>
|
|
<div class="queue-list" id="queueList"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
|
|
|
<script src="js/api.js?v=5"></script>
|
|
<script src="js/topbar-strip.js?v=1"></script>
|
|
<script>
|
|
const CHUNK = 10 * 1024 * 1024; // 10 MB chunks
|
|
const state = { projects: [], bins: [], queue: [], uploading: false };
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await loadProjects();
|
|
const params = new URLSearchParams(location.search);
|
|
if (params.get('project')) {
|
|
document.getElementById('projectSel').value = params.get('project');
|
|
await loadBins();
|
|
}
|
|
setupDropZone();
|
|
document.getElementById('clearDoneBtn').onclick = clearDone;
|
|
});
|
|
|
|
async function loadProjects() {
|
|
const r = await getProjects();
|
|
if (!r.success) return;
|
|
state.projects = r.data;
|
|
const sel = document.getElementById('projectSel');
|
|
sel.innerHTML = '<option value="">Select project…</option>' +
|
|
r.data.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
|
|
sel.onchange = loadBins;
|
|
}
|
|
|
|
async function loadBins() {
|
|
const projectId = document.getElementById('projectSel').value;
|
|
const sel = document.getElementById('binSel');
|
|
sel.innerHTML = '<option value="">No bin (project root)</option>';
|
|
if (!projectId) return;
|
|
const r = await getBins(projectId);
|
|
if (r.success && r.data.length) {
|
|
r.data.forEach(b => sel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
|
|
}
|
|
}
|
|
|
|
function setupDropZone() {
|
|
const zone = document.getElementById('dropZone');
|
|
const input = document.getElementById('fileInput');
|
|
|
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
|
|
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
|
zone.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
zone.classList.remove('drag-over');
|
|
enqueue([...e.dataTransfer.files]);
|
|
});
|
|
input.addEventListener('change', () => { enqueue([...input.files]); input.value = ''; });
|
|
}
|
|
|
|
function enqueue(files) {
|
|
const projectId = document.getElementById('projectSel').value;
|
|
if (!projectId) { toast('Select a project first', '', 'warning'); return; }
|
|
files = files.filter(f => f.type.startsWith('video/') || f.type.startsWith('audio/') || f.type.startsWith('image/'));
|
|
if (!files.length) { toast('No supported files selected', '', 'warning'); return; }
|
|
|
|
files.forEach(file => {
|
|
state.queue.push({ id: Math.random().toString(36).slice(2), file, status: 'queued', progress: 0, error: null });
|
|
});
|
|
|
|
renderQueue();
|
|
if (!state.uploading) processQueue();
|
|
}
|
|
|
|
async function processQueue() {
|
|
state.uploading = true;
|
|
const pending = state.queue.filter(i => i.status === 'queued');
|
|
for (const item of pending) {
|
|
await uploadFile(item);
|
|
renderQueue();
|
|
}
|
|
state.uploading = false;
|
|
toast('All uploads complete', `${pending.length} file${pending.length > 1 ? 's' : ''} ingested`, 'success');
|
|
}
|
|
|
|
async function uploadFile(item) {
|
|
item.status = 'active';
|
|
renderQueue();
|
|
const projectId = document.getElementById('projectSel').value;
|
|
const binId = document.getElementById('binSel').value || null;
|
|
const file = item.file;
|
|
|
|
try {
|
|
if (file.size <= 50 * 1024 * 1024) {
|
|
// Simple upload
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
fd.append('filename', file.name);
|
|
fd.append('projectId', projectId);
|
|
if (binId) fd.append('binId', binId);
|
|
fd.append('contentType', file.type);
|
|
const r = await simpleUpload(fd);
|
|
if (!r.success) throw new Error(r.error || 'Upload failed');
|
|
item.progress = 100;
|
|
} else {
|
|
// Multipart
|
|
const init = await initUpload({ filename: file.name, fileSize: file.size, contentType: file.type, projectId, binId });
|
|
if (!init.success) throw new Error(init.error || 'Failed to init upload');
|
|
const { assetId, uploadId, key } = init.data;
|
|
const parts = [];
|
|
const totalChunks = Math.ceil(file.size / CHUNK);
|
|
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
const chunk = file.slice(i * CHUNK, (i + 1) * CHUNK);
|
|
const fd = new FormData();
|
|
fd.append('file', chunk, file.name);
|
|
fd.append('uploadId', uploadId);
|
|
fd.append('key', key);
|
|
fd.append('partNumber', i + 1);
|
|
const pr = await uploadPart(fd);
|
|
if (!pr.success) throw new Error(pr.error || 'Part upload failed');
|
|
parts.push({ partNumber: i + 1, etag: pr.data.etag });
|
|
item.progress = Math.round(((i + 1) / totalChunks) * 95);
|
|
renderQueue();
|
|
}
|
|
|
|
const complete = await completeUpload({ uploadId, key, assetId, parts });
|
|
if (!complete.success) throw new Error(complete.error || 'Failed to finalize');
|
|
item.progress = 100;
|
|
}
|
|
item.status = 'done';
|
|
} catch (err) {
|
|
item.status = 'error';
|
|
item.error = err.message;
|
|
}
|
|
renderQueue();
|
|
}
|
|
|
|
function renderQueue() {
|
|
const list = document.getElementById('queueList');
|
|
const section = document.getElementById('queueSection');
|
|
const overallProgress = document.getElementById('overallProgress');
|
|
const clearBtn = document.getElementById('clearDoneBtn');
|
|
|
|
if (!state.queue.length) { section.style.display = 'none'; overallProgress.style.display = 'none'; return; }
|
|
|
|
section.style.display = 'block';
|
|
const done = state.queue.filter(i => i.status === 'done').length;
|
|
const total = state.queue.length;
|
|
const hasActive = state.queue.some(i => i.status === 'active' || i.status === 'queued');
|
|
|
|
document.getElementById('queueSummary').textContent = `${done} of ${total} complete`;
|
|
clearBtn.style.display = done > 0 ? '' : 'none';
|
|
|
|
// Overall bar
|
|
if (hasActive || done > 0) {
|
|
overallProgress.style.display = 'flex';
|
|
const totalPct = state.queue.reduce((s, i) => s + (i.status === 'done' ? 100 : i.progress), 0) / total;
|
|
document.getElementById('overallFill').style.width = totalPct + '%';
|
|
document.getElementById('overallPct').textContent = Math.round(totalPct) + '%';
|
|
document.getElementById('overallLabel').textContent = hasActive ? 'Uploading…' : 'Complete';
|
|
}
|
|
|
|
list.innerHTML = state.queue.map(item => {
|
|
const statusLabel = { queued:'Queued', active:'Uploading', done:'Done', error:'Error' }[item.status] || item.status;
|
|
const statusClass = { queued:'badge-idle', active:'badge-processing', done:'badge-ready', error:'badge-error' }[item.status];
|
|
const iconColor = { queued:'var(--text-tertiary)', active:'var(--accent)', done:'var(--status-green)', error:'var(--status-red)' }[item.status];
|
|
const icon = item.file.type.startsWith('video') ? videoIcon : item.file.type.startsWith('audio') ? audioIcon : docIcon;
|
|
return `<div class="queue-item status-${item.status}">
|
|
<div class="queue-item-icon" style="color:${iconColor}">${icon}</div>
|
|
<div class="queue-item-body">
|
|
<div class="queue-item-name">${esc(item.file.name)}</div>
|
|
<div class="queue-item-meta">
|
|
<span>${formatFileSize(item.file.size)}</span>
|
|
${item.error ? `<span style="color:var(--status-red)">${esc(item.error)}</span>` : ''}
|
|
</div>
|
|
${item.status === 'active' ? `<div class="queue-item-progress progress-bar" style="margin-top:var(--sp-1)"><div class="progress-fill" style="width:${item.progress}%"></div></div>` : ''}
|
|
</div>
|
|
<div class="queue-item-status">
|
|
<span class="badge ${statusClass}">${statusLabel}${item.status === 'active' ? ' ' + item.progress + '%' : ''}</span>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
const videoIcon = `<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><rect x="1" y="4" width="11" height="10" rx="1"/><path d="M12 8l5-2v6l-5-2"/></svg>`;
|
|
const audioIcon = `<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><path d="M9 2v14M6 5v8M3 7v4M12 5v8M15 7v4"/></svg>`;
|
|
const docIcon = `<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><path d="M5 2h6l4 4v10H5V2z"/><path d="M11 2v4h4"/></svg>`;
|
|
|
|
function clearDone() {
|
|
state.queue = state.queue.filter(i => i.status !== 'done');
|
|
renderQueue();
|
|
}
|
|
|
|
function toast(title, msg, type = 'info') {
|
|
const el = document.createElement('div');
|
|
el.className = `toast toast--${type}`;
|
|
el.innerHTML = `<div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg ? `<div class="toast-msg">${esc(msg)}</div>` : ''}</div>`;
|
|
document.getElementById('toastContainer').appendChild(el);
|
|
setTimeout(() => el.remove(), 4000);
|
|
}
|
|
|
|
function esc(s) {
|
|
if (!s) return '';
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (!bytes) return '';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
|
|
if (bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(1) + ' MB';
|
|
return (bytes/1024/1024/1024).toFixed(2) + ' GB';
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|