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
+36
View File
@@ -0,0 +1,36 @@
<?= view('partials/admin_nav') ?>
<h1 style="font-size:1.7rem;margin-bottom:1rem">Analytics (anonymous events only)</h1>
<div class="grid cols-4" style="margin-bottom:1.4rem">
<?php foreach (['tool_view' => 'Tool views', 'tool_start' => 'Jobs started', 'tool_success' => 'Successes', 'download' => 'Downloads'] as $key => $label): ?>
<div class="card"><p style="font-size:.78rem;text-transform:uppercase;color:var(--ink-500)"><?= esc($label) ?></p>
<h2 style="font-size:1.7rem;font-weight:800;margin:.15rem 0"><?= number_format($counts[$key] ?? 0) ?> <span class="hint">/24h</span></h2></div>
<?php endforeach ?>
</div>
<h2 class="section-title">Top tools (7 days)</h2>
<table style="width:100%;font-size:.87rem;border-collapse:collapse;background:#fff;border-radius:var(--radius-sm);box-shadow:var(--shadow-sm)">
<tr style="text-align:left;color:var(--ink-500)"><th style="padding:.65rem">Tool</th><th>Views</th><th>Starts</th><th>Success rate</th></tr>
<?php foreach ($topTools as $row): ?>
<tr style="border-top:1px solid #f2f2f5">
<td style="padding:.5rem .65rem"><a href="/<?= esc($row['tool_slug']) ?>"><?= esc(str_replace('-', ' ', $row['tool_slug'])) ?></a></td>
<td><?= number_format((float)$row['views']) ?></td>
<td><?= number_format((float)$row['starts']) ?></td>
<td><?= $row['success_rate'] === null ? '' : $row['success_rate'] . '%' ?></td>
</tr>
<?php endforeach ?>
</table>
<div class="grid cols-2" style="margin-top:1.4rem">
<div class="card">
<h3>Output formats (7d)</h3>
<?php if ($formats === []): ?><p class="hint">No downloads yet.</p><?php else: ?>
<ul style="padding-left:1.1rem"><?php foreach ($formats as $f): ?><li><strong><?= esc(strtoupper($f['format'])) ?></strong> — <?= number_format((float)$f['total']) ?></li><?php endforeach ?></ul>
<?php endif ?>
</div>
<div class="card">
<h3>Recent searches</h3>
<?php if ($searches === []): ?><p class="hint">None yet.</p><?php else: ?>
<ul style="padding-left:1.1rem"><?php foreach ($searches as $s): $m = json_decode((string)($s['meta'] ?? '{}'), true); ?><li><?= esc($m['term'] ?? '?') ?></li><?php endforeach ?></ul>
<?php endif ?>
</div>
</div>
+1
View File
@@ -0,0 +1 @@
<p>Use the inline editor on the categories index page.</p>
+15
View File
@@ -0,0 +1,15 @@
<?= view('partials/admin_nav') ?>
<h1 style="font-size:1.7rem;margin-bottom:1rem">Categories</h1>
<div class="grid cols-2">
<?php foreach ($categories as $c): ?>
<form method="post" action="/admin/categories/<?= (int)$c['id'] ?>" class="card" style="display:grid;gap:.5rem">
<?= csrf_field() ?>
<strong>/<?= esc($c['slug']) ?></strong>
<input type="hidden" name="sort_order" value="<?= (int)$c['sort_order'] ?>">
<input type="text" name="name" value="<?= esc($c['name']) ?>" style="border:1px solid #ddd;border-radius:8px;padding:.4rem .6rem">
<input type="text" name="tagline" value="<?= esc($c['tagline']) ?>" placeholder="Tagline" style="border:1px solid #ddd;border-radius:8px;padding:.4rem .6rem">
<input type="text" name="seo_title" value="<?= esc($c['seo_title']) ?>" placeholder="SEO title" style="border:1px solid #ddd;border-radius:8px;padding:.4rem .6rem">
<button type="submit" class="btn-primary" style="font-size:.85rem;padding:.5rem 1rem">Save</button>
</form>
<?php endforeach ?>
</div>
+43
View File
@@ -0,0 +1,43 @@
<?= view('partials/admin_nav') ?>
<h1 style="font-size:1.7rem;letter-spacing:-.02em;margin-bottom:1.2rem">Platform overview</h1>
<div class="grid cols-4" style="margin-bottom:1.6rem">
<?php
$stats = [
['Tools', $toolCount, '/admin/tools'],
['Guides', $guideCount, '/admin/guides'],
['Queued jobs', $queued, '/admin/jobs?status=queued'],
['Processing', $processing, '/admin/jobs?status=processing'],
];
foreach ($stats as [$label, $value, $href]): ?>
<a class="card" href="<?= esc($href) ?>">
<p style="font-size:.8rem;text-transform:uppercase;letter-spacing:.06em"><?= esc($label) ?></p>
<h2 style="font-size:2rem;font-weight:800;margin:.2rem 0 0"><?= (int) $value ?></h2>
</a>
<?php endforeach ?>
</div>
<div class="grid cols-2">
<div class="card">
<h3>Today</h3>
<p><?= number_format($viewsToday) ?> tool views · <?= number_format($startsToday) ?> jobs started · <?= (int) $failedToday ?> failed</p>
</div>
<div class="card">
<h3>Recent searches</h3>
<?php if ($searches === []): ?><p class="hint">No searches yet.</p><?php else: ?>
<ul style="margin:.3rem 0;padding-left:1.2rem">
<?php foreach ($searches as $s): $meta = json_decode((string) ($s['meta'] ?? '{}'), true); ?>
<li><?= esc($meta['term'] ?? '?') ?></li>
<?php endforeach ?>
</ul><?php endif ?>
</div>
</div>
<div class="card" style="margin-top:1rem">
<h3>Latest jobs</h3>
<table style="width:100%;font-size:.85rem;border-collapse:collapse">
<tr style="text-align:left;color:var(--ink-500)"><th>Job</th><th>Operation</th><th>Status</th><th>Created</th></tr>
<?php foreach ($recentJobs as $j): ?>
<tr><td><code><?= esc(substr($j['id'],0,8)) ?></code></td><td><?= esc($j['operation']) ?></td>
<td><span class="badge" style="<?= $j['status']==='completed'?'background:#ecfdf5;color:#047857':($j['status']==='failed'?'background:#fef2f2;color:#b91c1c':'') ?>"><?= esc($j['status']) ?></span></td>
<td><?= esc($j['created_at']) ?></td></tr>
<?php endforeach ?>
</table>
</div>
+23
View File
@@ -0,0 +1,23 @@
<?= view('partials/admin_nav') ?>
<h1 style="font-size:1.7rem;margin-bottom:1rem"><?= $guide ? 'Edit guide' : 'New guide' ?></h1>
<form method="post" action="/admin/guides<?= $guide ? '/' . (int)$guide['id'] : '' ?>" class="tool-panel" style="max-width:820px">
<?= csrf_field() ?>
<div class="opt-grid" style="padding-top:0">
<div><label>Title *</label><input type="text" name="title" required value="<?= esc($guide['title'] ?? '') ?>"></div>
<div><label>Slug *</label><input type="text" name="slug" required pattern="[a-z0-9-]+" value="<?= esc($guide['slug'] ?? '') ?>"></div>
<div><label>SEO title</label><input type="text" name="seo_title" value="<?= esc($guide['seo_title'] ?? '') ?>"></div>
<div><label>Status</label>
<select name="status"><?php foreach (['published','draft'] as $st): ?><option value="<?= $st ?>" <?= ($guide['status'] ?? 'draft')===$st?'selected':'' ?>><?= $st ?></option><?php endforeach ?></select></div>
</div>
<div style="margin-top:1rem"><label>Excerpt</label>
<textarea name="excerpt" class="tool-area" style="min-height:55px"><?= esc($guide['excerpt'] ?? '') ?></textarea></div>
<div class="opt-grid" style="margin-top:.6rem">
<div style="grid-column:span 2"><label>Tool slugs to interlink (comma separated)</label>
<input type="text" name="tool_slugs" value="<?= esc(implode(', ', json_decode((string)($guide['tool_slugs'] ?? '[]'), true) ?: [])) ?>"></div>
<div><label>Meta description</label>
<input type="text" name="seo_description" value="<?= esc($guide['seo_description'] ?? '') ?>"></div>
</div>
<div style="margin-top:1rem"><label>Body (Markdown)</label>
<textarea name="body_md" class="tool-area" style="min-height:420px;font-family:ui-monospace,monospace;font-size:.86rem"><?= esc($guide['body_md'] ?? '') ?></textarea></div>
<button class="btn-primary" type="submit" style="margin-top:1rem"><?= $guide ? 'Save' : 'Create' ?></button>
</form>
+17
View File
@@ -0,0 +1,17 @@
<?= view('partials/admin_nav') ?>
<div style="display:flex;align-items:center;gap:1rem">
<h1 style="font-size:1.7rem">Guides</h1>
<a class="btn-primary" href="/admin/guides/new" style="text-decoration:none;font-size:.9rem;padding:.55rem 1.2rem;margin-left:auto">+ New guide</a>
</div>
<table style="width:100%;font-size:.86rem;border-collapse:collapse;background:#fff;border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);margin-top:1rem">
<tr style="text-align:left;color:var(--ink-500)"><th style="padding:.7rem">Title</th><th>Status</th><th>Views</th><th>Published</th><th></th></tr>
<?php foreach ($guides as $g): ?>
<tr style="border-top:1px solid #f2f2f5">
<td style="padding:.6rem .7rem"><?= esc($g['title']) ?></td>
<td><span class="badge" <?= $g['status']!=='published' ? 'style="background:#fffbeb;color:#b45309"' : '' ?>><?= esc($g['status']) ?></span></td>
<td><?= (int)$g['views'] ?></td>
<td><?= esc(substr($g['published_at'],0,10)) ?></td>
<td><a href="/admin/guides/<?= (int)$g['id'] ?>/edit">Edit</a></td>
</tr>
<?php endforeach ?>
</table>
+22
View File
@@ -0,0 +1,22 @@
<?= view('partials/admin_nav') ?>
<h1 style="font-size:1.7rem;margin-bottom:1rem">Processing jobs</h1>
<div class="chips" style="margin-bottom:1rem">
<a class="chip" href="/admin/jobs">all (<?= $counts['completed'] + $counts['failed'] + $counts['queued'] + $counts['processing'] ?>)</a>
<a class="chip" href="/admin/jobs?status=queued">queued (<?= $counts['queued'] ?>)</a>
<a class="chip" href="/admin/jobs?status=processing">processing (<?= $counts['processing'] ?>)</a>
<a class="chip" href="/admin/jobs?status=completed">completed (<?= $counts['completed'] ?>)</a>
<a class="chip" href="/admin/jobs?status=failed">failed (<?= $counts['failed'] ?>)</a>
</div>
<table style="width:100%;font-size:.84rem;border-collapse:collapse;background:#fff;border-radius:var(--radius-sm);box-shadow:var(--shadow-sm)">
<tr style="text-align:left;color:var(--ink-500)"><th style="padding:.65rem">Job</th><th>Op</th><th>In</th><th>Status / stage</th><th>Error</th><th></th></tr>
<?php foreach ($jobs as $j): ?>
<tr style="border-top:1px solid #f2f2f5">
<td style="padding:.5rem .65rem"><code><?= esc(substr($j['id'],0,8)) ?></code><br><span class="hint"><?= esc($j['created_at']) ?></span></td>
<td><?= esc($j['operation']) ?></td>
<td><?= esc($j['input_name'] ?? '—') ?></td>
<td><span class="badge" style="<?= $j['status']==='completed'?'background:#ecfdf5;color:#047857':($j['status']==='failed'?'background:#fef2f2;color:#b91c1c':'') ?>"><?= esc($j['status']) ?></span> <?= esc($j['stage']) ?> · <?= (int)$j['progress'] ?>%</td>
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" class="hint"><?= esc(mb_substr($j['error'] ?? '', 0, 60)) ?></td>
<td><?php if (in_array($j['status'], ['failed', 'expired'], true)): ?><form method="post" action="/admin/jobs/<?= esc($j['id']) ?>/retry"><?= csrf_field() ?><button class="chip" type="submit" style="cursor:pointer;border:1px solid var(--brand-300,#c4b5fd)">Retry</button></form><?php endif ?></td>
</tr>
<?php endforeach ?>
</table>
+10
View File
@@ -0,0 +1,10 @@
<div class="wrap" style="max-width:460px;margin-top:4rem">
<h1 style="font-size:1.6rem;letter-spacing:-.02em">Admin sign-in</h1>
<?php if (! empty($error)): ?><p class="notice error"><?= esc($error) ?></p><?php endif ?>
<form method="post" action="/admin/login" class="tool-panel" style="padding:1.4rem">
<?= csrf_field() ?>
<label for="password" style="display:block;font-size:.85rem;font-weight:650;margin-bottom:.35rem">Password</label>
<input type="password" id="password" name="password" required autofocus style="width:100%" class="input-lg">
<button class="btn-primary" type="submit" style="margin-top:1rem;width:100%">Sign in</button>
</form>
</div>
+21
View File
@@ -0,0 +1,21 @@
<?= view('partials/admin_nav') ?>
<h1 style="font-size:1.7rem;margin-bottom:.4rem">SEO audit</h1>
<p class="hint" style="margin-bottom:1rem">
Auditing <?= $toolCount ?> tool pages + <?= $guideCount ?> guides ·
sitemap: <a href="<?= esc($sitemapUrl) ?>"><?= esc(parse_url($sitemapUrl, PHP_URL_PATH)) ?></a>
</p>
<div class="card" style="margin-bottom:1.4rem;display:flex;gap:2rem;align-items:center">
<h2 style="font-size:2.2rem;font-weight:800;color:<?= $criticalCount ? 'var(--err)' : 'var(--ok)' ?>;margin:0"><?= $criticalCount ?></h2>
<p style="margin:0">critical issue<?= $criticalCount===1?'':'s' ?><br><span class="hint">Warnings below do not block publishing.</span></p>
</div>
<table style="width:100%;font-size:.87rem;border-collapse:collapse;background:#fff;border-radius:var(--radius-sm);box-shadow:var(--shadow-sm)">
<tr style="text-align:left;color:var(--ink-500)"><th style="padding:.7rem">Severity</th><th>Page</th><th>Check</th></tr>
<?php foreach ($issues as $issue): ?>
<tr style="border-top:1px solid #f2f2f5">
<td style="padding:.5rem .7rem"><span class="badge" style="<?= $issue['severity']==='critical' ? 'background:#fef2f2;color:#b91c1c' : 'background:#fffbeb;color:#b45309' ?>"><?= esc($issue['severity']) ?></span></td>
<td><code><?= esc($issue['page']) ?></code></td>
<td><?= esc($issue['check']) ?></td>
</tr>
<?php endforeach ?>
<?php if ($issues === []): ?><tr><td colspan="3" style="padding:1rem">All checks passed.</td></tr><?php endif ?>
</table>
+17
View File
@@ -0,0 +1,17 @@
<?= view('partials/admin_nav') ?>
<h1 style="font-size:1.7rem;margin-bottom:1rem">Settings &amp; feature flags</h1>
<form method="post" action="/admin/settings" class="tool-panel" style="max-width:640px">
<?= csrf_field() ?>
<?php foreach ($flags as $key => $label): ?>
<label style="display:flex;gap:.7rem;align-items:flex-start;padding:.7rem 0;border-bottom:1px solid #f2f2f5">
<input type="checkbox" name="<?= esc($key) ?>" value="1" <?= ($values[$key] ?? '')==='1' ? 'checked' : '' ?>>
<span style="font-size:.93rem"><?= esc($label) ?></span>
</label>
<?php endforeach ?>
<div style="margin-top:1rem">
<label for="retention_hours" style="font-size:.85rem;font-weight:650">File retention override (hours, 172)</label>
<input id="retention_hours" name="retention_hours" type="number" min="1" max="72" placeholder="site default"
value="<?= esc($values['retention_hours'] ?? '') ?>" style="border:1.5px solid var(--ink-300);border-radius:9px;padding:.5rem .6rem;width:140px">
</div>
<button class="btn-primary" type="submit" style="margin-top:1.2rem">Save settings</button>
</form>
+27
View File
@@ -0,0 +1,27 @@
<?= view('partials/admin_nav') ?>
<h1 style="font-size:1.7rem;margin-bottom:1rem">System health</h1>
<div class="grid cols-2">
<div class="card">
<h3>Environment</h3>
<p>PHP <?= esc($phpVersion) ?> · CI <?= esc($ciVersion) ?> · <?= esc($environment) ?></p>
<p>Database: <?= $dbOk ? '<span style="color:var(--ok);font-weight:700">connected</span>' : '<span style="color:var(--err);font-weight:700">down</span>' ?></p>
<p>Worker heartbeat: <?= $workerBeat ? esc($workerBeat) : '<em>none — is a worker running?</em>' ?></p>
<p>yt-dlp capability: <?= $ytDlpEnabled ? 'enabled' : 'disabled by policy' ?></p>
</div>
<div class="card">
<h3>Storage</h3>
<p>Free disk: <?= number_format($diskFreeMb) ?> MB</p>
<p>Result files in temp storage: <?= (int)$storageFiles ?></p>
<p>Pending uploads: <?= (int)$incoming ?></p>
<p>Retention: every file expires after <strong><?= (int)$retentionHrs ?> h</strong> (<code>spark media:cleanup</code>)</p>
</div>
</div>
<div class="card" style="margin-top:1rem">
<h3>Processing binaries</h3>
<table style="width:100%;font-size:.88rem">
<?php foreach ($binaries as $name => $info): ?>
<tr><td style="padding:.35rem 0"><strong><?= esc($name) ?></strong></td><td class="hint"><?= esc($info['path'] ?: 'not configured') ?></td>
<td><?= $info['ok'] ? '<span style="color:var(--ok);font-weight:700">available</span>' : '<span style="color:var(--warn);font-weight:700">missing</span>' ?></td></tr>
<?php endforeach ?>
</table>
</div>
+34
View File
@@ -0,0 +1,34 @@
<?= view('partials/admin_nav') ?>
<h1 style="font-size:1.7rem;margin-bottom:1rem"><?= $tool ? 'Edit tool' : 'New tool' ?></h1>
<?php if ($e = session()->getFlashdata('error')): ?><p class="notice error"><?= esc($e) ?></p><?php endif ?>
<form method="post" action="/admin/tools<?= $tool ? '/' . (int)$tool['id'] : '' ?>" class="tool-panel" style="max-width:760px">
<?= csrf_field() ?>
<input type="hidden" name="_method" value="">
<div class="opt-grid" style="padding-top:0">
<div><label>Name *</label><input type="text" name="name" required value="<?= esc($tool['name'] ?? '') ?>"></div>
<div><label>Slug *</label><input type="text" name="slug" required pattern="[a-z0-9-]+" value="<?= esc($tool['slug'] ?? '') ?>"></div>
<div><label>Category</label>
<select name="category_id"><?php foreach ($categories as $c): ?><option value="<?= (int)$c['id'] ?>" <?= ($tool && (int)$tool['category_id']===(int)$c['id'])?'selected':'' ?>><?= esc($c['name']) ?></option><?php endforeach ?></select></div>
<div><label>Kind</label>
<select name="kind"><?php foreach (['upload','url','text'] as $k): ?><option value="<?= $k ?>" <?= ($tool['kind'] ?? '')===$k?'selected':'' ?>><?= $k ?></option><?php endforeach ?></select></div>
<div><label>Operation</label><input type="text" name="operation" value="<?= esc($tool['operation'] ?? 'convert') ?>" placeholder="convert / compress / trim …"></div>
<div><label>Primary format in</label><input type="text" name="format_in" value="" placeholder="mp4"></div>
<div><label>Primary format out</label><input type="text" name="format_out" value="" placeholder="mp3"></div>
<div><label>Status</label>
<select name="status"><?php foreach (['active','draft','inactive'] as $st): ?><option value="<?= $st ?>" <?= ($tool['status'] ?? 'active')===$st?'selected':'' ?>><?= $st ?></option><?php endforeach ?></select></div>
</div>
<div style="margin-top:1rem"><label>Short description</label>
<textarea name="short_description" class="tool-area" style="min-height:60px"><?= esc($tool['short_description'] ?? '') ?></textarea></div>
<div class="opt-grid" style="margin-top:1rem">
<div><label>SEO title</label><input type="text" name="seo_title" value="<?= esc($tool['seo_title'] ?? '') ?>"></div>
<div><label>H1</label><input type="text" name="h1" value="<?= esc($tool['h1'] ?? '') ?>"></div>
<div><label>Aliases (comma separated)</label><input type="text" name="aliases" value=""></div>
<div><label>Popularity</label><input type="number" name="popularity" value="<?= (int)($tool['popularity'] ?? 1000) ?>"></div>
</div>
<div style="margin-top:1rem"><label>Meta description</label>
<textarea name="seo_description" class="tool-area" style="min-height:60px"><?= esc($tool['seo_description'] ?? '') ?></textarea></div>
<div style="margin-top:1rem"><label>Intro paragraph</label>
<textarea name="intro" class="tool-area" style="min-height:80px"><?= esc($tool['intro'] ?? '') ?></textarea></div>
<p class="hint" style="margin-top:.8rem">Leave format fields blank for non-converter tools — SEO copy is auto-generated from the format pair where possible. Critical SEO checks block publishing.</p>
<button class="btn-primary" type="submit" style="margin-top:1rem"><?= $tool ? 'Save changes' : 'Create tool' ?></button>
</form>
+22
View File
@@ -0,0 +1,22 @@
<?= view('partials/admin_nav') ?>
<div style="display:flex;align-items:center;gap:1rem">
<h1 style="font-size:1.7rem">Tools (<?= count($tools) ?>)</h1>
<a class="btn-primary" href="/admin/tools/new" style="text-decoration:none;font-size:.9rem;padding:.55rem 1.2rem;margin-left:auto">+ New tool</a>
</div>
<form method="get" action="/admin/tools" class="hero-search" style="max-width:420px;margin:1rem 0;box-shadow:var(--shadow-sm)">
<input type="search" name="q" value="<?= esc($q) ?>" placeholder="Filter by name or slug…">
<button type="submit">Filter</button>
</form>
<table style="width:100%;font-size:.86rem;border-collapse:collapse;background:#fff;border-radius:var(--radius-sm);box-shadow:var(--shadow-sm)">
<tr style="text-align:left;color:var(--ink-500)"><th style="padding:.7rem">Tool</th><th>Category</th><th>Kind</th><th>Status</th><th>Pop.</th><th></th></tr>
<?php foreach ($tools as $t): ?>
<tr style="border-top:1px solid #f2f2f5">
<td style="padding:.6rem .7rem"><strong><?= esc($t['name']) ?></strong><br><code style="font-size:.78rem;color:var(--ink-500)">/<?= esc($t['slug']) ?></code></td>
<td><?= esc(service('finder')->categoryById((int)$t['category_id'])['name'] ?? '?') ?></td>
<td><?= esc($t['kind']) ?></td>
<td><span class="badge" <?= $t['status']!=='active' ? 'style="background:#fffbeb;color:#b45309"' : '' ?>><?= esc($t['status']) ?></span></td>
<td><?= (int)$t['popularity'] ?></td>
<td><a href="/admin/tools/<?= (int)$t['id'] ?>/edit">Edit</a></td>
</tr>
<?php endforeach ?>
</table>
+67
View File
@@ -0,0 +1,67 @@
<div class="wrap">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li aria-current="page"><?= esc($category['name']) ?></li>
</ol>
</nav>
<header class="tool-head">
<h1><?= esc($category['name']) ?></h1>
<p class="hint" style="font-size:.98rem;max-width:72ch"><?= esc($category['description']) ?></p>
</header>
<?php if (! empty($featured)): ?>
<section class="block tight" style="padding-top:.6rem">
<h2 class="section-title">Featured tools</h2>
<div class="grid cols-4">
<?php foreach ($featured as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
<?php endif ?>
<section class="block tight">
<h2 class="section-title">All <?= mb_strtolower((string) $category['name']) ?> (<?= count($tools) ?>)</h2>
<div class="grid cols-4">
<?php foreach ($tools as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
<?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 ?>
<?= view('partials/faq', ['faqs' => [
['q' => 'Are the ' . mb_strtolower($category['name'] ?? 'tools') . ' free to use?', 'a' => 'Yes, every tool in this category is free with no signup required.'],
['q' => 'Do I need to install anything?', 'a' => 'No. Everything runs in your browser or on our processing servers.'],
['q' => 'How long are my files kept?', 'a' => 'Uploaded and generated files are stored temporarily only, then deleted automatically.'],
]]) ?>
<section class="block tight">
<h2 class="section-title">Other categories</h2>
<div class="chips">
<?php foreach (service('finder')->categories() as $c): ?>
<?php if ($c['slug'] !== $category['slug']): ?>
<a class="chip" href="/<?= esc($c['slug']) ?>"><?= esc($c['name']) ?></a>
<?php endif ?>
<?php endforeach ?>
</div>
</section>
</div>
+40
View File
@@ -0,0 +1,40 @@
<div class="wrap">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol><li><a href="/">Home</a></li><li aria-current="page">All Tools</li></ol>
</nav>
<header class="tool-head">
<h1>All tools</h1>
<p class="hint" style="font-size:.98rem"><?= service('finder')->countTools() ?> free tools — search, filter by category, or browse AZ. Every tool is a normal link you can bookmark.</p>
</header>
<form method="get" action="/tools" class="hero-search" style="max-width:640px;margin:0 0 1.4rem;background:#fff;box-shadow:var(--shadow-sm);border:1px solid #eee" role="search">
<input type="search" name="q" value="<?= esc($query) ?>" placeholder="Search a tool… e.g. “compress mp4”, “jpg png”" aria-label="Search tools">
<button type="submit">Search</button>
</form>
<?php if (empty($query)): ?><div class="chips" style="margin-bottom:1.2rem">
<a class="chip" href="/tools" <?= $activeCat === '' ? 'style="border-color:var(--brand-500);color:var(--brand-600)"' : '' ?>>All</a>
<?php foreach ($categories as $c): ?>
<a class="chip" href="/tools?category=<?= esc($c['slug']) ?>" <?= $activeCat === $c['slug'] ? 'style="border-color:var(--brand-500);color:var(--brand-600)"' : '' ?>><?= esc($c['name']) ?></a>
<?php endforeach ?>
<span style="margin-left:auto"></span>
<a class="chip" href="/tools/popular">Popular</a>
<a class="chip" href="/tools/recent">Recently added</a>
<a class="chip" href="/tools/a-z">AZ</a>
</div><?php endif ?>
<section class="block tight">
<?php if ($tools === []): ?>
<p>No tools matched “<?= esc($query) ?>”. Try a shorter term like “mp3” or browse the categories above.</p>
<?php else: ?>
<div class="grid cols-4">
<?php foreach ($tools as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
<?php endif ?>
</section>
</div>
+15
View File
@@ -0,0 +1,15 @@
<div class="wrap">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol><li><a href="/">Home</a></li><li><a href="/tools">Tools</a></li><li aria-current="page"><?= esc($h1) ?></li></ol>
</nav>
<header class="tool-head"><h1><?= esc($h1) ?></h1><p class="hint"><?= esc($intro) ?></p></header>
<section class="block tight">
<div class="grid cols-4">
<?php foreach ($tools as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
</div>
+7
View File
@@ -0,0 +1,7 @@
<?php
use CodeIgniter\CLI\CLI;
CLI::error('ERROR: ' . $code);
CLI::write($message);
CLI::newLine();
+65
View File
@@ -0,0 +1,65 @@
<?php
use CodeIgniter\CLI\CLI;
// The main Exception
CLI::write('[' . $exception::class . ']', 'light_gray', 'red');
CLI::write($message);
CLI::write('at ' . CLI::color(clean_path($exception->getFile()) . ':' . $exception->getLine(), 'green'));
CLI::newLine();
$last = $exception;
while ($prevException = $last->getPrevious()) {
$last = $prevException;
CLI::write(' Caused by:');
CLI::write(' [' . $prevException::class . ']', 'red');
CLI::write(' ' . $prevException->getMessage());
CLI::write(' at ' . CLI::color(clean_path($prevException->getFile()) . ':' . $prevException->getLine(), 'green'));
CLI::newLine();
}
// The backtrace
if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) {
$backtraces = $last->getTrace();
if ($backtraces) {
CLI::write('Backtrace:', 'green');
}
foreach ($backtraces as $i => $error) {
$padFile = ' '; // 4 spaces
$padClass = ' '; // 7 spaces
$c = str_pad($i + 1, 3, ' ', STR_PAD_LEFT);
if (isset($error['file'])) {
$filepath = clean_path($error['file']) . ':' . $error['line'];
CLI::write($c . $padFile . CLI::color($filepath, 'yellow'));
} else {
CLI::write($c . $padFile . CLI::color('[internal function]', 'yellow'));
}
$function = '';
if (isset($error['class'])) {
$type = ($error['type'] === '->') ? '()' . $error['type'] : $error['type'];
$function .= $padClass . $error['class'] . $type . $error['function'];
} elseif (! isset($error['class']) && isset($error['function'])) {
$function .= $padClass . $error['function'];
}
$args = implode(', ', array_map(static fn ($value): string => match (true) {
is_object($value) => 'Object(' . $value::class . ')',
is_array($value) => $value !== [] ? '[...]' : '[]',
$value === null => 'null', // return the lowercased version
default => var_export($value, true),
}, array_values($error['args'] ?? [])));
$function .= '(' . $args . ')';
CLI::write($function);
CLI::newLine();
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
// On the CLI, we still want errors in productions
// so just use the exception template.
include __DIR__ . '/error_exception.php';
+194
View File
@@ -0,0 +1,194 @@
:root {
--main-bg-color: #fff;
--main-text-color: #555;
--dark-text-color: #222;
--light-text-color: #c7c7c7;
--brand-primary-color: #DC4814;
--light-bg-color: #ededee;
--dark-bg-color: #404040;
}
body {
height: 100%;
background: var(--main-bg-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
color: var(--main-text-color);
font-weight: 300;
margin: 0;
padding: 0;
}
h1 {
font-weight: lighter;
font-size: 3rem;
color: var(--dark-text-color);
margin: 0;
}
h1.headline {
margin-top: 20%;
font-size: 5rem;
}
.text-center {
text-align: center;
}
p.lead {
font-size: 1.6rem;
}
.container {
max-width: 75rem;
margin: 0 auto;
padding: 1rem;
}
.header {
background: var(--light-bg-color);
color: var(--dark-text-color);
margin-top: 2.17rem;
}
.header .container {
padding: 1rem;
}
.header h1 {
font-size: 2.5rem;
font-weight: 500;
}
.header p {
font-size: 1.2rem;
margin: 0;
line-height: 2.5;
}
.header a {
color: var(--brand-primary-color);
margin-left: 2rem;
display: none;
text-decoration: none;
}
.header:hover a {
display: inline;
}
.environment {
background: var(--brand-primary-color);
color: var(--main-bg-color);
text-align: center;
padding: calc(4px + 0.2083vw);
width: 100%;
top: 0;
position: fixed;
}
.source {
background: #343434;
color: var(--light-text-color);
padding: 0.5em 1em;
border-radius: 5px;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 0.85rem;
margin: 0;
overflow-x: scroll;
}
.source span.line {
line-height: 1.4;
}
.source span.line .number {
color: #666;
}
.source .line .highlight {
display: block;
background: var(--dark-text-color);
color: var(--light-text-color);
}
.source span.highlight .number {
color: #fff;
}
.tabs {
list-style: none;
list-style-position: inside;
margin: 0;
padding: 0;
margin-bottom: -1px;
}
.tabs li {
display: inline;
}
.tabs a:link,
.tabs a:visited {
padding: 0 1rem;
line-height: 2.7;
text-decoration: none;
color: var(--dark-text-color);
background: var(--light-bg-color);
border: 1px solid rgba(0,0,0,0.15);
border-bottom: 0;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
display: inline-block;
}
.tabs a:hover {
background: var(--light-bg-color);
border-color: rgba(0,0,0,0.15);
}
.tabs a.active {
background: var(--main-bg-color);
color: var(--main-text-color);
}
.tab-content {
background: var(--main-bg-color);
border: 1px solid rgba(0,0,0,0.15);
}
.content {
padding: 1rem;
}
.hide {
display: none;
}
.alert {
margin-top: 2rem;
display: block;
text-align: center;
line-height: 3.0;
background: #d9edf7;
border: 1px solid #bcdff1;
border-radius: 5px;
color: #31708f;
}
table {
width: 100%;
overflow: hidden;
}
th {
text-align: left;
border-bottom: 1px solid #e7e7e7;
padding-bottom: 0.5rem;
}
td {
padding: 0.2rem 0.5rem 0.2rem 0;
}
tr:hover td {
background: #f1f1f1;
}
td pre {
white-space: pre-wrap;
}
.trace a {
color: inherit;
}
.trace table {
width: auto;
}
.trace tr td:first-child {
min-width: 5em;
font-weight: bold;
}
.trace td {
background: var(--light-bg-color);
padding: 0 1rem;
}
.trace td pre {
margin: 0;
}
.args {
display: none;
}
+116
View File
@@ -0,0 +1,116 @@
var tabLinks = new Array();
var contentDivs = new Array();
function init()
{
// Grab the tab links and content divs from the page
var tabListItems = document.getElementById('tabs').childNodes;
console.log(tabListItems);
for (var i = 0; i < tabListItems.length; i ++)
{
if (tabListItems[i].nodeName == "LI")
{
var tabLink = getFirstChildWithTagName(tabListItems[i], 'A');
var id = getHash(tabLink.getAttribute('href'));
tabLinks[id] = tabLink;
contentDivs[id] = document.getElementById(id);
}
}
// Assign onclick events to the tab links, and
// highlight the first tab
var i = 0;
for (var id in tabLinks)
{
tabLinks[id].onclick = showTab;
tabLinks[id].onfocus = function () {
this.blur()
};
if (i == 0)
{
tabLinks[id].className = 'active';
}
i ++;
}
// Hide all content divs except the first
var i = 0;
for (var id in contentDivs)
{
if (i != 0)
{
console.log(contentDivs[id]);
contentDivs[id].className = 'content hide';
}
i ++;
}
}
function showTab()
{
var selectedId = getHash(this.getAttribute('href'));
// Highlight the selected tab, and dim all others.
// Also show the selected content div, and hide all others.
for (var id in contentDivs)
{
if (id == selectedId)
{
tabLinks[id].className = 'active';
contentDivs[id].className = 'content';
}
else
{
tabLinks[id].className = '';
contentDivs[id].className = 'content hide';
}
}
// Stop the browser following the link
return false;
}
function getFirstChildWithTagName(element, tagName)
{
for (var i = 0; i < element.childNodes.length; i ++)
{
if (element.childNodes[i].nodeName == tagName)
{
return element.childNodes[i];
}
}
}
function getHash(url)
{
var hashPos = url.lastIndexOf('#');
return url.substring(hashPos + 1);
}
function toggle(elem)
{
elem = document.getElementById(elem);
if (elem.style && elem.style['display'])
{
// Only works with the "style" attr
var disp = elem.style['display'];
}
else if (elem.currentStyle)
{
// For MSIE, naturally
var disp = elem.currentStyle['display'];
}
else if (window.getComputedStyle)
{
// For most other browsers
var disp = document.defaultView.getComputedStyle(elem, null).getPropertyValue('display');
}
// Toggle the state of the "display" style
elem.style.display = disp == 'block' ? 'none' : 'block';
return false;
}
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= lang('Errors.badRequest') ?></title>
<style>
div.logo {
height: 200px;
width: 155px;
display: inline-block;
opacity: 0.08;
position: absolute;
top: 2rem;
left: 50%;
margin-left: -73px;
}
body {
height: 100%;
background: #fafafa;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #777;
font-weight: 300;
}
h1 {
font-weight: lighter;
letter-spacing: normal;
font-size: 3rem;
margin-top: 0;
margin-bottom: 0;
color: #222;
}
.wrap {
max-width: 1024px;
margin: 5rem auto;
padding: 2rem;
background: #fff;
text-align: center;
border: 1px solid #efefef;
border-radius: 0.5rem;
position: relative;
}
pre {
white-space: normal;
margin-top: 1.5rem;
}
code {
background: #fafafa;
border: 1px solid #efefef;
padding: 0.5rem 1rem;
border-radius: 5px;
display: block;
}
p {
margin-top: 1.5rem;
}
.footer {
margin-top: 2rem;
border-top: 1px solid #efefef;
padding: 1em 2em 0 2em;
font-size: 85%;
color: #999;
}
a:active,
a:link,
a:visited {
color: #dd4814;
}
</style>
</head>
<body>
<div class="wrap">
<h1>400</h1>
<p>
<?php if (ENVIRONMENT !== 'production') : ?>
<?= nl2br(esc($message)) ?>
<?php else : ?>
<?= lang('Errors.sorryBadRequest') ?>
<?php endif; ?>
</p>
</div>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= lang('Errors.pageNotFound') ?></title>
<style>
div.logo {
height: 200px;
width: 155px;
display: inline-block;
opacity: 0.08;
position: absolute;
top: 2rem;
left: 50%;
margin-left: -73px;
}
body {
height: 100%;
background: #fafafa;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #777;
font-weight: 300;
}
h1 {
font-weight: lighter;
letter-spacing: normal;
font-size: 3rem;
margin-top: 0;
margin-bottom: 0;
color: #222;
}
.wrap {
max-width: 1024px;
margin: 5rem auto;
padding: 2rem;
background: #fff;
text-align: center;
border: 1px solid #efefef;
border-radius: 0.5rem;
position: relative;
}
pre {
white-space: normal;
margin-top: 1.5rem;
}
code {
background: #fafafa;
border: 1px solid #efefef;
padding: 0.5rem 1rem;
border-radius: 5px;
display: block;
}
p {
margin-top: 1.5rem;
}
.footer {
margin-top: 2rem;
border-top: 1px solid #efefef;
padding: 1em 2em 0 2em;
font-size: 85%;
color: #999;
}
a:active,
a:link,
a:visited {
color: #dd4814;
}
</style>
</head>
<body>
<div class="wrap">
<h1>404</h1>
<p>
<?php if (ENVIRONMENT !== 'production') : ?>
<?= nl2br(esc($message)) ?>
<?php else : ?>
<?= lang('Errors.sorryCannotFind') ?>
<?php endif; ?>
</p>
</div>
</body>
</html>
+429
View File
@@ -0,0 +1,429 @@
<?php
use CodeIgniter\HTTP\Header;
use CodeIgniter\CodeIgniter;
$errorId = uniqid('error', true);
?>
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex">
<title><?= esc($title) ?></title>
<style>
<?= preg_replace('#[\r\n\t ]+#', ' ', file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.css')) ?>
</style>
<script>
<?= file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.js') ?>
</script>
</head>
<body onload="init()">
<!-- Header -->
<div class="header">
<div class="environment">
Displayed at <?= esc(date('H:i:s')) ?> &mdash;
PHP: <?= esc(PHP_VERSION) ?> &mdash;
CodeIgniter: <?= esc(CodeIgniter::CI_VERSION) ?> --
Environment: <?= ENVIRONMENT ?>
</div>
<div class="container">
<h1><?= esc($title), esc($exception->getCode() ? ' #' . $exception->getCode() : '') ?></h1>
<p>
<?= nl2br(esc($exception->getMessage())) ?>
<a href="https://www.duckduckgo.com/?q=<?= urlencode($title . ' ' . preg_replace('#\'.*\'|".*"#Us', '', $exception->getMessage())) ?>"
rel="noreferrer" target="_blank">search &rarr;</a>
</p>
</div>
</div>
<!-- Source -->
<div class="container">
<p><b><?= esc(clean_path($file)) ?></b> at line <b><?= esc($line) ?></b></p>
<?php if (is_file($file)) : ?>
<div class="source">
<?= static::highlightFile($file, $line, 15); ?>
</div>
<?php endif; ?>
</div>
<div class="container">
<?php
$last = $exception;
while ($prevException = $last->getPrevious()) {
$last = $prevException;
?>
<pre>
Caused by:
<?= esc($prevException::class), esc($prevException->getCode() ? ' #' . $prevException->getCode() : '') ?>
<?= nl2br(esc($prevException->getMessage())) ?>
<a href="https://www.duckduckgo.com/?q=<?= urlencode($prevException::class . ' ' . preg_replace('#\'.*\'|".*"#Us', '', $prevException->getMessage())) ?>"
rel="noreferrer" target="_blank">search &rarr;</a>
<?= esc(clean_path($prevException->getFile()) . ':' . $prevException->getLine()) ?>
</pre>
<?php
}
?>
</div>
<?php if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) : ?>
<div class="container">
<ul class="tabs" id="tabs">
<li><a href="#backtrace">Backtrace</a></li>
<li><a href="#server">Server</a></li>
<li><a href="#request">Request</a></li>
<li><a href="#response">Response</a></li>
<li><a href="#files">Files</a></li>
<li><a href="#memory">Memory</a></li>
</ul>
<div class="tab-content">
<!-- Backtrace -->
<div class="content" id="backtrace">
<ol class="trace">
<?php foreach ($trace as $index => $row) : ?>
<li>
<p>
<!-- Trace info -->
<?php if (isset($row['file']) && is_file($row['file'])) : ?>
<?php
if (isset($row['function']) && in_array($row['function'], ['include', 'include_once', 'require', 'require_once'], true)) {
echo esc($row['function'] . ' ' . clean_path($row['file']));
} else {
echo esc(clean_path($row['file']) . ' : ' . $row['line']);
}
?>
<?php else: ?>
{PHP internal code}
<?php endif; ?>
<!-- Class/Method -->
<?php if (isset($row['class'])) : ?>
&nbsp;&nbsp;&mdash;&nbsp;&nbsp;<?= esc($row['class'] . $row['type'] . $row['function']) ?>
<?php if (! empty($row['args'])) : ?>
<?php $argsId = $errorId . 'args' . $index ?>
( <a href="#" onclick="return toggle('<?= esc($argsId, 'attr') ?>');">arguments</a> )
<div class="args" id="<?= esc($argsId, 'attr') ?>">
<table cellspacing="0">
<?php
$params = null;
// Reflection by name is not available for closure function
if (! str_ends_with($row['function'], '}')) {
$mirror = isset($row['class']) ? new ReflectionMethod($row['class'], $row['function']) : new ReflectionFunction($row['function']);
$params = $mirror->getParameters();
}
foreach ($row['args'] as $key => $value) : ?>
<tr>
<td><code><?= esc(isset($params[$key]) ? '$' . $params[$key]->name : "#{$key}") ?></code></td>
<td><pre><?= esc(print_r($value, true)) ?></pre></td>
</tr>
<?php endforeach ?>
</table>
</div>
<?php else : ?>
()
<?php endif; ?>
<?php endif; ?>
<?php if (! isset($row['class']) && isset($row['function'])) : ?>
&nbsp;&nbsp;&mdash;&nbsp;&nbsp; <?= esc($row['function']) ?>()
<?php endif; ?>
</p>
<!-- Source? -->
<?php if (isset($row['file']) && is_file($row['file']) && isset($row['class'])) : ?>
<div class="source">
<?= static::highlightFile($row['file'], $row['line']) ?>
</div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
</div>
<!-- Server -->
<div class="content" id="server">
<?php foreach (['_SERVER', '_SESSION'] as $var) : ?>
<?php
if (empty($GLOBALS[$var]) || ! is_array($GLOBALS[$var])) {
continue;
} ?>
<h3>$<?= esc($var) ?></h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($GLOBALS[$var] as $key => $value) : ?>
<tr>
<td><?= esc($key) ?></td>
<td>
<?php if (is_string($value)) : ?>
<?= esc($value) ?>
<?php else: ?>
<pre><?= esc(print_r($value, true)) ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach ?>
<!-- Constants -->
<?php $constants = get_defined_constants(true); ?>
<?php if (! empty($constants['user'])) : ?>
<h3>Constants</h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($constants['user'] as $key => $value) : ?>
<tr>
<td><?= esc($key) ?></td>
<td>
<?php if (is_string($value)) : ?>
<?= esc($value) ?>
<?php else: ?>
<pre><?= esc(print_r($value, true)) ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Request -->
<div class="content" id="request">
<?php $request = service('request'); ?>
<table>
<tbody>
<tr>
<td style="width: 10em">Path</td>
<td><?= esc($request->getUri()) ?></td>
</tr>
<tr>
<td>HTTP Method</td>
<td><?= esc($request->getMethod()) ?></td>
</tr>
<tr>
<td>IP Address</td>
<td><?= esc($request->getIPAddress()) ?></td>
</tr>
<tr>
<td style="width: 10em">Is AJAX Request?</td>
<td><?= $request->isAJAX() ? 'yes' : 'no' ?></td>
</tr>
<tr>
<td>Is CLI Request?</td>
<td><?= $request->isCLI() ? 'yes' : 'no' ?></td>
</tr>
<tr>
<td>Is Secure Request?</td>
<td><?= $request->isSecure() ? 'yes' : 'no' ?></td>
</tr>
<tr>
<td>User Agent</td>
<td><?= esc($request->getUserAgent()->getAgentString()) ?></td>
</tr>
</tbody>
</table>
<?php $empty = true; ?>
<?php foreach (['_GET', '_POST', '_COOKIE'] as $var) : ?>
<?php
if (empty($GLOBALS[$var]) || ! is_array($GLOBALS[$var])) {
continue;
} ?>
<?php $empty = false; ?>
<h3>$<?= esc($var) ?></h3>
<table style="width: 100%">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($GLOBALS[$var] as $key => $value) : ?>
<tr>
<td><?= esc($key) ?></td>
<td>
<?php if (is_string($value)) : ?>
<?= esc($value) ?>
<?php else: ?>
<pre><?= esc(print_r($value, true)) ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach ?>
<?php if ($empty) : ?>
<div class="alert">
No $_GET, $_POST, or $_COOKIE Information to show.
</div>
<?php endif; ?>
<?php $headers = $request->headers(); ?>
<?php if (! empty($headers)) : ?>
<h3>Headers</h3>
<table>
<thead>
<tr>
<th>Header</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($headers as $name => $value) : ?>
<tr>
<td><?= esc($name, 'html') ?></td>
<td>
<?php
if ($value instanceof Header) {
echo esc($value->getValueLine(), 'html');
} else {
foreach ($value as $i => $header) {
echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html');
}
}
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Response -->
<?php
$response = service('response');
$response->setStatusCode(http_response_code());
?>
<div class="content" id="response">
<table>
<tr>
<td style="width: 15em">Response Status</td>
<td><?= esc($response->getStatusCode() . ' - ' . $response->getReasonPhrase()) ?></td>
</tr>
</table>
<?php $headers = $response->headers(); ?>
<?php if (! empty($headers)) : ?>
<h3>Headers</h3>
<table>
<thead>
<tr>
<th>Header</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($headers as $name => $value) : ?>
<tr>
<td><?= esc($name, 'html') ?></td>
<td>
<?php
if ($value instanceof Header) {
echo esc($response->getHeaderLine($name), 'html');
} else {
foreach ($value as $i => $header) {
echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html');
}
}
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Files -->
<div class="content" id="files">
<?php $files = get_included_files(); ?>
<ol>
<?php foreach ($files as $file) :?>
<li><?= esc(clean_path($file)) ?></li>
<?php endforeach ?>
</ol>
</div>
<!-- Memory -->
<div class="content" id="memory">
<table>
<tbody>
<tr>
<td>Memory Usage</td>
<td><?= esc(static::describeMemory(memory_get_usage(true))) ?></td>
</tr>
<tr>
<td style="width: 12em">Peak Memory Usage:</td>
<td><?= esc(static::describeMemory(memory_get_peak_usage(true))) ?></td>
</tr>
<tr>
<td>Memory Limit:</td>
<td><?= esc(ini_get('memory_limit')) ?></td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /tab-content -->
</div> <!-- /container -->
<?php endif; ?>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex">
<title><?= lang('Errors.whoops') ?></title>
<style>
<?= preg_replace('#[\r\n\t ]+#', ' ', file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.css')) ?>
</style>
</head>
<body>
<div class="container text-center">
<h1 class="headline"><?= lang('Errors.whoops') ?></h1>
<p class="lead"><?= lang('Errors.weHitASnag') ?></p>
</div>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<div class="center-page error-hero">
<code class="big"><?= (int) $code ?></code>
<h1><?= esc($title) ?></h1>
<p style="color:var(--ink-500)"><?= esc($message) ?></p>
<p><a class="btn-primary" href="/tools" style="text-decoration:none;display:inline-block;margin-top:1rem">Browse all tools</a></p>
</div>
+6
View File
@@ -0,0 +1,6 @@
<div class="center-page error-hero">
<code class="big">429</code>
<h1>Too many requests</h1>
<p style="color:var(--ink-500)">You've hit the rate limit. Please wait a little while before trying again limits keep the tools fast for everyone.</p>
<p><a href="/tools"> Back to the tool directory</a></p>
</div>
+24
View File
@@ -0,0 +1,24 @@
<section class="hero" style="padding:2.6rem 1.25rem 2.8rem">
<h1 style="font-size:clamp(1.5rem,4vw,2.2rem)">Tool not found</h1>
<p class="tagline">That page doesn't exist — but the toolbox is right here.</p>
<form class="hero-search" action="/search" method="get" role="search">
<input type="search" name="q" placeholder="Search a tool…" aria-label="Search tools">
<button type="submit">Search</button>
</form>
</section>
<div class="wrap">
<section class="block tight">
<h2 class="section-title">Popular tools</h2>
<div class="grid cols-3">
<?php foreach ($popular as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
<section class="block tight">
<h2 class="section-title">Browse categories</h2>
<div class="chips">
<?php foreach ($categories as $c): ?><a class="chip" href="/<?= esc($c['slug']) ?>"><?= esc($c['name']) ?></a><?php endforeach ?>
</div>
</section>
</div>
+37
View File
@@ -0,0 +1,37 @@
<div class="wrap">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol><li><a href="/">Home</a></li><li><a href="/blog">Guides</a></li><li aria-current="page"><?= esc(mb_substr((string) $guide['title'], 0, 60)) ?></li></ol>
</nav>
<article class="prose">
<header class="tool-head">
<span class="badge"><?= date('M j, Y', strtotime((string) $guide['published_at'])) ?> · <?= (int) $guide['reading_minutes'] ?> min read</span>
<h1 style="font-size:clamp(1.7rem,4vw,2.4rem);font-weight:830;letter-spacing:-.03em;margin:.6rem 0 .5rem;line-height:1.15"><?= esc($guide['title']) ?></h1>
<p class="hint" style="font-size:1rem"><?= esc($guide['excerpt']) ?></p>
</header>
<?= $html ?>
<?php if (! empty($tools)): ?>
<section class="block tight">
<h2 class="section-title">Tools used in this guide</h2>
<div class="grid cols-3">
<?php foreach ($tools as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
<?php endif ?>
<?php if (! empty($related)): ?>
<section class="block tight">
<h2 class="section-title">Keep reading</h2>
<ul>
<?php foreach ($related as $rel): ?>
<li><a href="/blog/<?= esc($rel['slug']) ?>"><?= esc($rel['title']) ?></a> — <?= esc(mb_substr((string) $rel['excerpt'], 0, 80)) ?>…</li>
<?php endforeach ?>
</ul>
</section>
<?php endif ?>
</article>
</div>
+21
View File
@@ -0,0 +1,21 @@
<div class="wrap">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol><li><a href="/">Home</a></li><li aria-current="page">Guides</li></ol>
</nav>
<header class="tool-head">
<h1>Guides &amp; tutorials</h1>
<p class="hint">Practical, no-fluff walkthroughs for converting, compressing and editing media. Every guide links the exact tools for the job.</p>
</header>
<section class="block tight">
<div class="grid cols-2">
<?php foreach ($guides as $guide): ?>
<a class="card" href="/blog/<?= esc($guide['slug']) ?>">
<span class="badge"><?= date('M j, Y', strtotime((string) $guide['published_at'])) ?> · <?= (int) $guide['reading_minutes'] ?> min read</span>
<h3 style="margin-top:.5rem;font-size:1.12rem"><?= esc($guide['title']) ?></h3>
<p><?= esc($guide['excerpt']) ?></p>
</a>
<?php endforeach ?>
</div>
</section>
</div>
+110
View File
@@ -0,0 +1,110 @@
<section class="hero">
<span class="badge" style="background:#ffffff22;color:#fff"><?= $toolCount ?> free tools</span>
<h1>Free Online Video, YouTube &amp; Image Tools</h1>
<p class="tagline">Fast, simple and free tools for downloading, converting, compressing and editing media online.</p>
<form class="hero-search" action="/search" method="get" role="search">
<input type="search" name="q" placeholder="Search a tool… e.g. “youtube mp3”" aria-label="Search tools"
autocomplete="off" data-suggest>
<button type="submit">Search</button>
</form>
<div class="hero-stats">No signup · No watermarks · Files deleted automatically</div>
</section>
<div class="wrap">
<section class="block">
<h2 class="section-title">Popular tools <a class="see-all" href="/tools/popular">See all →</a></h2>
<div class="grid cols-4">
<?php foreach ($popular as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
<section class="block tight">
<h2 class="section-title">Browse by category</h2>
<div class="grid cols-4">
<?php foreach ($categories as $category): ?>
<a class="card cat-card" href="/<?= esc($category['slug']) ?>">
<span class="icon-wrap"><?= icon($category['icon']) ?></span>
<h3><?= esc($category['name']) ?></h3>
<p><?= esc($category['tagline']) ?></p>
<span class="count">
<?= count(service('finder')->toolsInCategory($category['slug'])) ?> tools →
</span>
</a>
<?php endforeach ?>
</div>
</section>
<section class="block tight">
<h2 class="section-title">Most used this week</h2>
<div class="grid cols-4">
<?php foreach ($mostUsed as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
<section class="block tight">
<h2 class="section-title">Recently added</h2>
<div class="grid cols-3">
<?php foreach ($recently as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
<section class="block tight">
<h2 class="section-title">Why use our tools?</h2>
<div class="grid cols-3">
<div class="card">
<span class="icon-wrap"><?= icon('shield') ?></span>
<h3>Private by design</h3>
<p>Files are processed on isolated workers and deleted automatically. Text utilities run entirely in your browser.</p>
</div>
<div class="card">
<span class="icon-wrap"><?= icon('swap') ?></span>
<h3>No installation</h3>
<p>Everything runs in the browser or on our servers — nothing to download, install or update.</p>
</div>
<div class="card">
<span class="icon-wrap"><?= icon('sparkles') ?></span>
<h3>Genuinely fast</h3>
<p>Lightweight pages, no ad bloat, no fake buttons. Tools start working the moment you arrive.</p>
</div>
</div>
</section>
<section class="block tight">
<h2 class="section-title">How it works</h2>
<ol class="steps">
<li><strong>Paste or upload</strong> Drop a file onto the upload area, or paste a link — whatever the tool asks for.</li>
<li><strong>Choose your output</strong> Pick format and quality when it matters; sensible defaults are preselected.</li>
<li><strong>Download the result</strong> Processing happens on dedicated servers with live progress, then you save the file.</li>
</ol>
</section>
<?= view('partials/faq', ['faqs' => [
['q' => 'Are these tools really free?', 'a' => 'Yes. Every tool on ' . config('Site')->name . ' is free to use without an account. Heavy processing is rate-limited to keep the service fast for everyone.'],
['q' => 'What can I do with these online media tools?', 'a' => 'Convert videos between MP4, WebM, MOV and more, extract audio as MP3 or WAV, compress oversized files, convert and optimize images, merge PDFs, generate QR codes and much more — ' . $toolCount . ' tools in total.'],
['q' => 'Do you store my files?', 'a' => 'Only temporarily. Uploads and results live in temporary storage for a short retention window (typically two hours) and are then deleted automatically along with all job metadata links.'],
['q' => 'Can I convert videos without installing software?', 'a' => 'That is the whole point — every conversion runs server-side through FFmpeg, so your device only needs a browser.'],
['q' => 'Is it legal to download YouTube videos?', 'a' => 'It depends on the content and your jurisdiction: your own uploads and properly licensed material are fine; commercial content generally requires permission from the rights holder or YouTube Premium\'s official offline mode. Our documentation explains how we handle platform terms.'],
]]) ?>
<section class="block tight">
<h2 class="section-title">Latest guides <a class="see-all" href="/blog">All guides →</a></h2>
<div class="grid cols-2">
<?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, 130)) ?>…</p>
</a>
<?php endforeach ?>
</div>
</section>
</div>
+87
View File
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="<?= esc(service('request')->getLocale()) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $seo->renderTitle() ?></title>
<meta name="description" content="">
<?= $seo->renderHead() ?>
<link rel="stylesheet" href="/assets/css/app.css">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="<?= esc(config('Site')->name) ?>">
<script type="application/ld+json"><?= $seo->renderJsonLd() ?></script>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<div class="inner">
<a class="logo" href="/">
<span class="logo-mark" aria-hidden="true"><?= icon('layers') ?></span>
<span><?= esc(config('Site')->name) ?></span>
</a>
<button class="nav-toggle" id="nav-toggle" aria-expanded="false" aria-controls="main-nav" aria-label="Open menu"><?= icon('menu') ?></button>
<nav class="main-nav" id="main-nav" aria-label="Main navigation">
<?php foreach (service('finder')->categories() as $c): ?>
<a href="/<?= esc($c['slug']) ?>"><?= esc($c['name']) ?></a>
<?php endforeach ?>
<a href="/blog">Guides</a>
<a href="/tools">All Tools</a>
</nav>
</div>
</header>
<main id="main">
<?= $viewContent ?? '' ?>
</main>
<footer class="site-footer">
<div class="wrap footer-cols">
<div>
<a class="logo" href="/" style="margin-bottom:.6rem">
<span class="logo-mark" aria-hidden="true"><?= icon('layers') ?></span>
<span><?= esc(config('Site')->name) ?></span>
</a>
<p style="color:var(--ink-500);max-width:34ch;margin-top:.6rem"><?= esc(config('Site')->tagline) ?> <?= esc(config('Site')->description) ?></p>
</div>
<div>
<h4>Categories</h4>
<ul>
<?php foreach (service('finder')->categories() as $c): ?>
<li><a href="/<?= esc($c['slug']) ?>"><?= esc($c['name']) ?></a></li>
<?php endforeach ?>
</ul>
</div>
<div>
<h4>Popular</h4>
<ul>
<li><a href="/youtube-to-mp3">YouTube to MP3</a></li>
<li><a href="/video-compressor">Video Compressor</a></li>
<li><a href="/jpg-to-png">JPG to PNG</a></li>
<li><a href="/pdf-merger">PDF Merger</a></li>
</ul>
</div>
<div>
<h4>About</h4>
<ul>
<li><a href="/blog">Guides</a></li>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/terms">Terms of Service</a></li>
<li><a href="/dmca">DMCA / Copyright</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</div>
</div>
<div class="wrap footer-bottom">
<span>© <?= date('Y') ?> <?= esc(config('Site')->name) ?>. All rights reserved.</span>
<span>Files are processed temporarily and deleted automatically.</span>
</div>
</footer>
<script src="/assets/js/app.js" defer></script>
<?php if (! empty($pageScript)): ?>
<script src="/assets/js/<?= esc($pageScript) ?>.js" defer></script>
<?php endif ?>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<nav class="chips" style="margin-bottom:1.4rem" aria-label="Admin navigation">
<a class="chip" href="/admin">Overview</a>
<a class="chip" href="/admin/tools">Tools</a>
<a class="chip" href="/admin/categories">Categories</a>
<a class="chip" href="/admin/guides">Guides</a>
<a class="chip" href="/admin/seo">SEO Audit</a>
<a class="chip" href="/admin/analytics">Analytics</a>
<a class="chip" href="/admin/jobs">Jobs</a>
<a class="chip" href="/admin/system">System</a>
<a class="chip" href="/admin/settings">Settings</a>
<span style="margin-left:auto"></span>
<a class="chip" href="/admin/logout">Log out</a>
</nav>
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* FAQ accordion — native <details>, zero JS.
* Expected vars: $faqs list of ['q'=>,'a'=>]
*/
if (empty($faqs)) {
return;
}
?>
<section class="block tight faq" aria-labelledby="faq-title">
<h2 class="section-title" id="faq-title">Frequently asked questions</h2>
<?php foreach ($faqs as $faq): ?>
<details>
<summary><?= esc($faq['q']) ?></summary>
<div class="answer"><p><?= esc($faq['a']) ?></p></div>
</details>
<?php endforeach ?>
</section>
+11
View File
@@ -0,0 +1,11 @@
<?php
/**
* Renders one tool card.
* Expected vars: $tool (registry row)
*/
?>
<a class="card" href="/<?= esc($tool['slug']) ?>">
<span class="icon-wrap"><?= icon($tool['icon'] ?? 'file') ?></span>
<h3><?= esc($tool['name']) ?></h3>
<p><?= esc(mb_substr((string) $tool['short_description'], 0, 110) . (mb_strlen((string) $tool['short_description']) > 110 ? '…' : '')) ?></p>
</a>
+11
View File
@@ -0,0 +1,11 @@
<?php /** Related tools grid. Vars: $tools, $title */ ?>
<?php if (! empty($tools)): ?>
<section class="block tight">
<h2 class="section-title"><?= esc($title ?? 'Related tools') ?><a class="see-all" href="/tools/a-z">All tools →</a></h2>
<div class="grid cols-4">
<?php foreach ($tools as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
<?php endif ?>
+39
View File
@@ -0,0 +1,39 @@
<div class="wrap">
<header class="tool-head">
<h1>Search tools</h1>
<?php if ($query !== ''): ?>
<p class="hint">Results for “<strong><?= esc($query) ?></strong>” — <?= count($results) ?> match<?= count($results) === 1 ? '' : 'es' ?>.</p>
<?php endif ?>
</header>
<form class="hero-search" action="/search" method="get" role="search" style="max-width:640px;margin:0 0 1.5rem;box-shadow:var(--shadow-sm);border:1px solid #eee">
<input type="search" name="q" value="<?= esc($query) ?>" placeholder="Search a tool…" aria-label="Search tools">
<button type="submit">Search</button>
</form>
<?php if ($results !== []): ?>
<div class="grid cols-3">
<?php foreach ($results as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
<?php elseif ($query !== ''): ?>
<p>No tool matched. Try “youtube”, “compress”, or one of these:</p>
<?php endif ?>
<section class="block tight">
<h2 class="section-title">Popular right now</h2>
<div class="grid cols-3">
<?php foreach ($popular as $tool): ?>
<?= view('partials/tool_card', ['tool' => $tool]) ?>
<?php endforeach ?>
</div>
</section>
<section class="block tight">
<h2 class="section-title">Categories</h2>
<div class="chips">
<?php foreach ($categories as $c): ?><a class="chip" href="/<?= esc($c['slug']) ?>"><?= esc($c['name']) ?></a><?php endforeach ?>
</div>
</section>
</div>
+13
View File
@@ -0,0 +1,13 @@
<div class="wrap">
<article class="prose" style="margin-top:2.5rem">
<h1>Contact</h1>
<p>Reach us at <strong>hello@example.com</strong> replace with your production inbox before launch. Typical response time is two business days.</p>
<h3>Common topics</h3>
<ul>
<li><strong>Abuse reports</strong> include URLs and timestamps.</li>
<li><strong>DMCA / copyright</strong> see the <a href="/dmca">DMCA policy</a> for notice requirements.</li>
<li><strong>API access</strong> tell us your expected volume.</li>
<li><strong>Bug reports</strong> the tool page URL plus what you expected vs. saw helps enormously.</li>
</ul>
</article>
</div>
+12
View File
@@ -0,0 +1,12 @@
<div class="wrap">
<article class="prose" style="margin-top:2.5rem">
<h1>Cookie Policy</h1>
<p><em>Last updated: <?= date('F Y') ?></em></p>
<p><?= esc(config('Site')->name) ?> sets exactly one cookie:</p>
<table>
<tr><th>Name</th><th>Purpose</th><th>Lifetime</th></tr>
<tr><td><code>tv_session</code></td><td>Links your uploaded file to its processing job so you can download results. Contains no personal data.</td><td>Session (deleted when you close the browser)</td></tr>
</table>
<p>No analytics cookies, advertising cookies, or third-party trackers are used. Because the only cookie is strictly functional, no consent banner is required.</p>
</article>
</div>
+18
View File
@@ -0,0 +1,18 @@
<div class="wrap">
<article class="prose" style="margin-top:2.5rem">
<h1>DMCA &amp; Copyright Policy</h1>
<p><?= esc(config('Site')->name) ?> respects intellectual property rights. We respond promptly to valid notices under the DMCA and comparable laws.</p>
<h2>Filing a notice</h2>
<p>Send takedown requests through the <a href="/contact">contact page</a>, including:</p>
<ol class="steps" style="list-style:none;counter-reset:none">
<li style="--x:0"><strong>Identification</strong>of the copyrighted work you believe is infringed.</li>
<li><strong>The URL</strong> on our site allegedly hosting infringing material.</li>
<li><strong>Your contact details</strong> and a good-faith statement of authority.</li>
</ol>
<p>Valid notices are actioned within two business days.</p>
<h2>Design of this service</h2>
<p>This platform does not host a public library of user files: uploads are private to the uploading session and expire automatically. Tools that interact with third-party platforms are designed around applicable platform terms and can be disabled by operators.</p>
</article>
</div>
+31
View File
@@ -0,0 +1,31 @@
<div class="wrap">
<article class="prose" style="margin-top:2.5rem">
<h1>Privacy Policy</h1>
<p><em>Last updated: <?= date('F Y') ?></em></p>
<p><?= esc(config('Site')->name) ?> is built to need as little of your data as possible. This page explains exactly what is stored and what is not.</p>
<h2>Files you process</h2>
<ul>
<li>Uploads and generated results live in <strong>temporary storage</strong> only for processing.</li>
<li>They are deleted automatically after a short retention window (two hours by default), or sooner once you download them and the job expires.</li>
<li>Files are stored under random UUID names, outside the web root, and never shared with other users or third parties.</li>
</ul>
<h2>What we log</h2>
<ul>
<li><strong>Anonymous usage events</strong>: which tools are viewed and used, output formats chosen, success/failure counts. Session and IP values are one-way salted hashes — we cannot reverse them to identify anyone.</li>
<li><strong>Search terms</strong>, in aggregate, to find missing tools.</li>
<li>No advertising cookies, no cross-site trackers, no fingerprinting scripts.</li>
</ul>
<h2>Cookies</h2>
<p>One functional session cookie keeps your upload/result association while you use a tool (see the <a href="/cookies">cookie policy</a>). No consent banner is needed because no tracking cookies exist.</p>
<h2>Third-party requests</h2>
<p>YouTube metadata lookups query YouTube's public oEmbed endpoint with your pasted URL. Thumbnail downloads fetch directly from Google's image CDN. Nothing else about you is sent anywhere.</p>
<h2>Data removal</h2>
<p>Because files expire quickly and logs are anonymous hashes, there is generally nothing to delete. For any question, use the <a href="/contact">contact page</a>.</p>
</article>
</div>
+27
View File
@@ -0,0 +1,27 @@
<div class="wrap">
<article class="prose" style="margin-top:2.5rem">
<h1>Terms of Service</h1>
<p><em>Last updated: <?= date('F Y') ?></em></p>
<h2>Acceptable use</h2>
<p><?= esc(config('Site')->name) ?> provides media utilities. You agree to use them only for content you own or are permitted to process, and never to:</p>
<ul>
<li>circumvent DRM or technical protection measures;</li>
<li>access private or paid content without authorization;</li>
<li>infringe copyright or other rights of third parties;</li>
<li>upload malware or attempt to abuse the infrastructure.</li>
</ul>
<h2>Third-party platforms</h2>
<p>For platforms such as YouTube, downloading capability is provided for your own uploads and properly licensed material. You are responsible for complying with that platform's terms and applicable law. Features may be disabled by operators where required.</p>
<h2>No warranty</h2>
<p>Services are provided "as is" without warranties of any kind. Processing results depend on input quality; always keep originals of important files.</p>
<h2>Rate limits</h2>
<p>To keep the service fast for everyone, fair-use limits apply per IP address and session. Automated access requires an API token with its own quota.</p>
<h2>Contact</h2>
<p>Questions about these terms: see the <a href="/contact">contact page</a>.</p>
</article>
</div>
+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>
File diff suppressed because one or more lines are too long