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
+248
View File
@@ -0,0 +1,248 @@
<?php
/**
* Individual tool page. Vars:
* $tool registry row, $category, $related, $conversions, $guides, $capabilities
*/
$kind = $tool['kind'];
$isYt = str_starts_with((string) $tool['operation'], 'yt_');
$ytEnabled = (bool) ($capabilities['yt_dlp'] ?? false);
$binariesOk = true;
foreach (($capabilities['binaries'] ?? []) as $ok) {
$binariesOk = $binariesOk && $ok;
}
$pageScript = match (true) {
$kind === 'text' => 'text-tool',
$kind === 'url' => 'url-tool',
default => 'upload-tool',
};
?>
<div class="wrap">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<?php if ($category !== null): ?><li><a href="/<?= esc($category['slug']) ?>"><?= esc($category['name']) ?></a></li><?php endif ?>
<li aria-current="page"><?= esc($tool['name']) ?></li>
</ol>
</nav>
<header class="tool-head">
<h1><?= esc($tool['h1'] ?: $tool['name']) ?></h1>
<p class="hint" style="font-size:.98rem;max-width:70ch"><?= esc($tool['intro']) ?></p>
</header>
<section class="tool-panel" id="tool-panel" data-tool="<?= esc($tool['slug']) ?>" data-operation="<?= esc((string) $tool['operation']) ?>" data-kind="<?= esc($kind) ?>">
<?php if ($isYt && ! $ytEnabled): ?>
<div class="notice">
<strong>Stream downloading is disabled on this server.</strong><br>
The operator has not enabled video/audio downloads (platform terms require explicit opt-in).
Metadata tools like the <a href="/youtube-thumbnail-downloader">thumbnail downloader</a> and
<a href="/youtube-metadata-viewer">metadata viewer</a> work normally.
</div>
<?php elseif (! $binariesOk): ?>
<div class="notice">
<strong>This tool is temporarily unavailable on this server.</strong><br>
A required processing component is missing. Please check back later.
</div>
<?php elseif ($kind === 'url'): ?>
<form class="url-form" id="tool-form" method="post" action="/api/jobs">
<label for="url-input" class="sr-only" style="position:absolute;left:-9999px">Media URL</label>
<div class="url-row">
<input class="input-lg" type="url" id="url-input" name="url"
placeholder="Paste YouTube URL… https://www.youtube.com/watch?v=…" required>
<button class="btn-primary" type="submit"><?= str_contains(strtolower($tool['name']), 'download') || str_contains(strtolower($tool['name']), 'mp3') ? 'Convert' : 'Analyze' ?></button>
</div>
<span class="hint">Works with watch links, Shorts and share links.</span>
</form>
<div class="result-panel" id="info-panel"></div>
<div class="status-flow" id="status-flow" hidden>
<div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
<ul class="stages">
<li data-stage="preparing">Preparing</li>
<li data-stage="fetching">Fetching information</li>
<li data-stage="processing">Processing</li>
<li data-stage="converting">Converting</li>
<li data-stage="complete">Complete</li>
</ul>
</div>
<div class="result-panel" id="result-panel"></div>
<p class="notice error" id="error-box" hidden role="alert"></p>
<?php elseif ($kind === 'text'): ?>
<form id="tool-form" data-client="<?= esc((string) $tool['operation']) ?>">
<textarea class="tool-area" id="tool-input" placeholder="<?= esc(textPlaceholder((string) $tool['operation'])) ?>"></textarea>
<?php if ($tool['operation'] === 'qr_generate'): ?>
<div class="opt-grid" style="padding-top:.8rem">
<div><label for="qr-size">Size</label>
<select id="qr-size">
<option value="200">Small (200px)</option>
<option value="300" selected>Medium (300px)</option>
<option value="500">Large (500px)</option>
<option value="800">Print (800px)</option>
</select></div>
<div><label for="qr-level">Error correction</label>
<select id="qr-level">
<option value="L">Low — smallest code</option>
<option value="M" selected>Medium — recommended</option>
<option value="Q">Quartile</option>
<option value="H">High — best for print/logos</option>
</select></div>
</div>
<?php endif ?>
<div class="opt-grid" style="padding-top:.8rem">
<button class="btn-primary" type="submit" style="min-width:180px"><?= icon('arrow') ?> Run</button>
</div>
</form>
<div class="result-panel" id="client-result"></div>
<?php else: ?>
<form id="tool-form" enctype="multipart/form-data">
<div class="dropzone" id="dropzone" tabindex="0" role="button" aria-label="Upload a file">
<?= icon('file') ?>
<strong>Drop your file here</strong>
<span>or click to browse —
<?= esc(implode(', ', array_map('strtoupper', array_slice($tool['input_formats'] ?: ['any format'], 0, 8)))) ?>
· up to <?= (int) ($tool['max_upload_mb'] ?: config('Site')->maxUploadMb) ?> MB
</span>
<input type="file" id="file-input" hidden
accept="<?= esc('.' . implode(',.', array_merge($tool['input_formats'] ?: [], ['mp4', 'jpg', 'png', 'webp', 'gif', 'mp3', 'wav', 'pdf', 'm4a', 'flac', 'ogg', 'mov', 'mkv', 'avi', 'srt', 'vtt', 'svg', 'bmp', 'avif']))) ?>">
</div>
<div class="file-chip" id="file-chip" hidden>
<?= icon('file') ?>
<span class="name" id="chip-name"></span>
<span class="size hint" id="chip-size"></span>
<button type="button" id="chip-remove">×</button>
</div>
<?php if (! empty($tool['output_formats'])): ?>
<details class="advanced">
<summary>Advanced options</summary>
<div class="opt-grid">
<?php if (count($tool['output_formats']) > 1 && in_array($tool['operation'], ['convert', 'extract_audio'], true)): ?>
<div><label for="opt-format">Output format</label>
<select id="opt-format">
<?php foreach ($tool['output_formats'] as $fmt): ?>
<option value="<?= esc($fmt) ?>" <?= $fmt === $tool['primary_output_format'] ? 'selected' : '' ?>><?= esc(strtoupper($fmt)) ?></option>
<?php endforeach ?>
</select></div>
<?php endif ?>
<?php if (in_array(\App\Libraries\FormatCatalog::family((string) ($tool['primary_output_format'] ?? '')), ['audio'], true) || in_array($tool['operation'], ['extract_audio'], true)): ?>
<div><label for="opt-bitrate">Audio bitrate</label>
<select id="opt-bitrate"><option value="320">320 kbps</option><option value="192" selected>192 kbps</option><option value="128">128 kbps</option></select></div>
<?php endif ?>
<?php if ($tool['operation'] === 'compress'): ?>
<div><label for="opt-level">Compression strength</label>
<select id="opt-level"><option value="20">Sharper file</option><option value="28" selected>Balanced</option><option value="34">Smaller file</option></select></div>
<?php endif ?>
<?php if (in_array($tool['operation'], ['resize', 'resolution'], true)): ?>
<div><label for="opt-width">Width (px)</label><input id="opt-width" type="number" min="16" max="7680" placeholder="auto"></div>
<div><label for="opt-height">Height (px)</label><input id="opt-height" type="number" min="16" max="4320" placeholder="auto"></div>
<?php endif ?>
<?php if (in_array($tool['operation'], ['trim', 'cut'], true)): ?>
<div><label for="opt-start">Start (seconds)</label><input id="opt-start" type="number" min="0" step="0.1" value="0"></div>
<div><label for="opt-end">End (seconds)</label><input id="opt-end" type="number" min="0" step="0.1" placeholder="end of file"></div>
<?php endif ?>
<?php if ($tool['operation'] === 'rotate'): ?>
<div><label for="opt-degrees">Rotation</label>
<select id="opt-degrees"><option value="90">90° clockwise</option><option value="180">180°</option><option value="270">90° counter-clockwise</option></select></div>
<?php endif ?>
<?php if ($tool['operation'] === 'flip'): ?>
<div><label for="opt-direction">Direction</label>
<select id="opt-direction"><option value="horizontal">Horizontal mirror</option><option value="vertical">Vertical flip</option></select></div>
<?php endif ?>
<?php if ($tool['operation'] === 'speed'): ?>
<div><label for="opt-factor">Speed factor</label>
<input id="opt-factor" type="number" step="0.05" min="0.25" max="4" value="1.5"></div>
<?php endif ?>
<?php if (in_array($tool['operation'], ['fps', 'extract_frames'], true)): ?>
<div><label for="opt-fps">Frames per second</label>
<input id="opt-fps" type="number" min="1" max="60" value="<?= $tool['operation'] === 'fps' ? 30 : 2 ?>"></div>
<?php endif ?>
<?php if ($tool['operation'] === 'volume'): ?>
<div><label for="opt-gain">Gain (dB)</label>
<input id="opt-gain" type="number" min="-30" max="30" value="6"></div>
<?php endif ?>
<?php if ($tool['operation'] === 'pdf_compress'): ?>
<div><label for="opt-preset">Preset</label>
<select id="opt-preset"><option value="ebook" selected>Ebook (150 dpi)</option><option value="screen">Screen (72 dpi)</option><option value="printer">Printer (300 dpi)</option></select></div>
<?php endif ?>
<?php if ($tool['operation'] === 'pdf_extract_pages'): ?>
<div><label for="opt-pages">Pages</label>
<input id="opt-pages" type="text" placeholder="e.g. 1-3 or 2,5,7"></div>
<?php endif ?>
</div>
</details>
<?php endif ?>
<div style="margin-top:1rem">
<button class="btn-primary" type="submit" id="convert-btn" disabled><?= icon('arrow') ?> Convert</button>
</div>
</form>
<div class="status-flow" id="status-flow" hidden>
<div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
<ul class="stages">
<li data-stage="preparing">Preparing</li>
<li data-stage="processing">Processing</li>
<li data-stage="converting">Converting</li>
<li data-stage="complete">Complete</li>
</ul>
</div>
<div class="result-panel" id="result-panel"></div>
<p class="notice error" id="error-box" hidden role="alert"></p>
<?php endif ?>
</section>
<?php if (! empty($tool['how_to'])): ?>
<section class="block tight">
<h2 class="section-title">How to use the <?= esc(mb_strtolower((string) $tool['name'])) ?></h2>
<ol class="steps">
<?php foreach ($tool['how_to'] as $step): ?>
<li><strong><?= esc($step['name']) ?></strong><?= esc($step['text']) ?></li>
<?php endforeach ?>
</ol>
</section>
<?php endif ?>
<?php if (! empty($tool['input_formats']) || ! empty($tool['output_formats'])): ?>
<section class="block tight">
<h2 class="section-title">Supported formats</h2>
<?php if (! empty($tool['input_formats'])): ?>
<p style="margin:.2rem 0 .3rem;font-weight:650;font-size:.9rem">Input</p>
<div class="chips"><?php foreach ($tool['input_formats'] as $f): ?><a class="chip" href="/<?= esc($f) ?>-converter"><?= esc(strtoupper($f)) ?></a><?php endforeach ?></div>
<?php endif ?>
<?php if (! empty($tool['output_formats'])): ?>
<p style="margin:.9rem 0 .3rem;font-weight:650;font-size:.9rem">Output</p>
<div class="chips"><?php foreach ($tool['output_formats'] as $f): ?><a class="chip" href="/<?= esc(($tool['primary_input_format'] ?? '') !== '' ? $tool['primary_input_format'] . '-to-' . $f : $f . '-converter') ?>"><?= esc(strtoupper($f)) ?></a><?php endforeach ?></div>
<?php endif ?>
</section>
<?php endif ?>
<?= view('partials/faq', ['faqs' => is_array($tool['faqs']) ? $tool['faqs'] : []]) ?>
<?php if (! empty($guides)): ?>
<section class="block tight">
<h2 class="section-title">Related guides</h2>
<div class="grid cols-3">
<?php foreach ($guides as $guide): ?>
<a class="card" href="/blog/<?= esc($guide['slug']) ?>">
<span class="badge">Guide · <?= (int) $guide['reading_minutes'] ?> min</span>
<h3 style="margin-top:.5rem"><?= esc($guide['title']) ?></h3>
<p><?= esc(mb_substr((string) $guide['excerpt'], 0, 120)) ?>…</p>
</a>
<?php endforeach ?>
</div>
</section>
<?php endif ?>
<?php if (! empty($conversions)): ?>
<?= view('partials/tools_grid', ['tools' => $conversions, 'title' => 'Related conversions']) ?>
<?php endif ?>
<?= view('partials/tools_grid', ['tools' => $related, 'title' => 'Related tools']) ?>
</div>