Toolvana: production-ready media tools platform (CodeIgniter 4)
- 134-tool registry with programmatic SEO (unique titles/H1/descriptions, JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter) - DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD), Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG) - Security: SSRF guard, MIME validation, rate limits, API-key auth, bcrypt admin login, security headers - Admin panel: dashboard, tools/categories/guides CRUD, SEO audit, analytics, job inspector with retry, system health, feature flags - Docker deployment (nginx + web/api FPM pools + scalable workers), PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/* Upload-tool flow: dropzone → upload → job → poll → download. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var panel = document.getElementById('tool-panel');
|
||||
if (!panel) return;
|
||||
var slug = panel.dataset.tool;
|
||||
|
||||
var form = document.getElementById('tool-form');
|
||||
var zone = document.getElementById('dropzone');
|
||||
var fileInput = document.getElementById('file-input');
|
||||
var chip = document.getElementById('file-chip');
|
||||
var chipName = document.getElementById('chip-name');
|
||||
var chipSize = document.getElementById('chip-size');
|
||||
var chipRemove = document.getElementById('chip-remove');
|
||||
var submitBtn = document.getElementById('convert-btn');
|
||||
var statusFlow = document.getElementById('status-flow');
|
||||
var progressFill = document.getElementById('progress-fill');
|
||||
var resultPanel = document.getElementById('result-panel');
|
||||
var errorBox = document.getElementById('error-box');
|
||||
|
||||
var uploadedFile = null;
|
||||
var pollTimer = null;
|
||||
var currentJobId = null;
|
||||
|
||||
function fmtSize(bytes) {
|
||||
var units = ['B', 'KB', 'MB', 'GB'], i = 0, n = bytes;
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||
return (Math.round(n * 10) / 10) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorBox.textContent = msg;
|
||||
errorBox.hidden = false;
|
||||
track('tool_failure', {});
|
||||
}
|
||||
|
||||
function hideError() { errorBox.hidden = true; }
|
||||
|
||||
function track(name, extra) {
|
||||
try {
|
||||
navigator.sendBeacon &&
|
||||
navigator.sendBeacon('/api/events', new Blob([JSON.stringify(Object.assign({ name: name, tool: slug }, extra))], { type: 'application/json' }));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function setStage(stage, progress) {
|
||||
if (!statusFlow) return;
|
||||
statusFlow.hidden = false;
|
||||
document.querySelectorAll('.stages li').forEach(function (li) {
|
||||
li.classList.toggle('done', li.dataset.stage !== stage);
|
||||
li.classList.toggle('active', li.dataset.stage === stage);
|
||||
});
|
||||
if (typeof progress === 'number') progressFill.style.width = Math.max(6, progress) + '%';
|
||||
}
|
||||
|
||||
function acceptFile(file) {
|
||||
if (!file) return;
|
||||
hideError();
|
||||
chipName.textContent = file.name;
|
||||
chipSize.textContent = fmtSize(file.size);
|
||||
chip.hidden = false;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
// ---- dropzone interactions ----
|
||||
['dragover', 'dragenter'].forEach(function (ev) {
|
||||
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
});
|
||||
['dragleave', 'drop'].forEach(function (ev) {
|
||||
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('dragover'); });
|
||||
});
|
||||
zone.addEventListener('drop', function (e) {
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
acceptFile(fileInput.files[0]);
|
||||
});
|
||||
zone.addEventListener('click', function () { fileInput.click(); });
|
||||
zone.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
|
||||
fileInput.addEventListener('change', function () { acceptFile(fileInput.files[0]); });
|
||||
chipRemove.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
chip.hidden = true;
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
|
||||
// ---- submit → upload → start job ----
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length || !submitBtn.disabled === false) {
|
||||
if (!fileInput.files.length) return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
hideError();
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('tool', slug);
|
||||
fd.append('file', fileInput.files[0]);
|
||||
|
||||
setStage('preparing', 4);
|
||||
|
||||
fetch('/upload', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error(res.body.error || 'Upload failed.');
|
||||
uploadedFile = res.body.file;
|
||||
return startJob(res.body);
|
||||
})
|
||||
.catch(function (err) {
|
||||
showError(err.message || 'Upload failed. Please check the file and try again.');
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
function opt(id) {
|
||||
var el = document.getElementById(id);
|
||||
return el ? el.value : undefined;
|
||||
}
|
||||
|
||||
function startJob(uploadInfo) {
|
||||
var params = {};
|
||||
['format', 'bitrate', 'quality', 'width', 'height', 'start', 'end',
|
||||
'x', 'y', 'crop_width', 'crop_height', 'degrees', 'direction',
|
||||
'factor', 'fps', 'gain_db', 'level', 'preset', 'pages'].forEach(function (k) {
|
||||
var v = opt('opt-' + k);
|
||||
if (v !== undefined && v !== '') params[k] = isNaN(v) || v === '' ? v : Number(v);
|
||||
});
|
||||
|
||||
// map option ids to API param names where they differ
|
||||
if (params.quality !== undefined) params.quality = Number(params.quality); // level select feeds "level"
|
||||
if (opt('opt-level') !== undefined && opt('opt-level') !== '') { params.level = Number(opt('opt-level')); delete params.quality; }
|
||||
if (params.gain_db !== undefined) params.gain_db = Number(params.gain_db);
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('tool', slug);
|
||||
Object.keys(params).forEach(function (k) { fd.append(k, params[k]); });
|
||||
|
||||
return fetch('/jobs/start', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.ok) throw new Error(data.error || 'Could not start processing.');
|
||||
currentJobId = data.job_id;
|
||||
track('conversion_format', { format: params.format || (document.getElementById('opt-format') || {}).value || '' });
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (!currentJobId) return;
|
||||
fetch('/api/jobs/' + currentJobId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.ok) throw new Error('lost');
|
||||
var stageMap = { preparing: 'preparing', fetching: 'processing', processing: 'processing', converting: 'converting' };
|
||||
if (data.status === 'completed') {
|
||||
finish(data);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'failed') {
|
||||
showError(data.message || 'Processing failed. Please try again.');
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
setStage(stageMap[data.stage] || 'processing', data.progress);
|
||||
pollTimer = setTimeout(poll, 1500);
|
||||
})
|
||||
.catch(function () { pollTimer = setTimeout(poll, 3000); });
|
||||
}
|
||||
|
||||
function finish(data) {
|
||||
setStage('complete', 100);
|
||||
clearTimeout(pollTimer);
|
||||
var ext = (data.output_name || '').split('.').pop() || 'file';
|
||||
var isMedia = /^(jpg|jpeg|png|webp|gif|mp4|webm|mp3|wav|m4a|pdf)$/.test(ext);
|
||||
|
||||
resultPanel.innerHTML =
|
||||
'<div class="result-meta">' +
|
||||
'<span class="badge">Done</span>' +
|
||||
'<strong>' + escapeHtml(data.output_name || 'result.' + ext) + '</strong>' +
|
||||
'<span class="hint">' + fmtSize(data.output_size || 0) + '</span>' +
|
||||
'<a class="btn-primary" style="text-decoration:none;padding:.55rem 1.3rem;font-size:.95rem" href="' + data.download_url + '" download>Download</a>' +
|
||||
'</div>' +
|
||||
(isMedia ? '<div style="margin-top:.9rem;text-align:center"><img src="' + data.preview_url + '" alt="Result preview" loading="lazy" style="max-height:260px;border-radius:10px"></div>'.replace('<img', ext.match(/^(mp4|webm)$/) ? '<video controls autoplay muted loop><source src="' + data.preview_url + '" type="video/mp4"></video><img style="display:none"' : '<img') : '');
|
||||
|
||||
resultPanel.classList.add('show');
|
||||
submitBtn.disabled = false;
|
||||
track('download', { format: ext });
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user