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:
deepseek
2026-08-23 07:10:30 +00:00
commit beaf0e1f37
217 changed files with 19619 additions and 0 deletions
+194
View File
@@ -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;
}
})();