feat(ui): migrate to Tailwind (compiled) + Flowbite JS; new navbar/layout; Docker CSS build

- Add multi-stage CSS build that compiles Tailwind into app/static/tw.css
- Add Tailwind config with dark tokens (bg/nav/card) and purge globs
- Add assets/input.css (@tailwind base/components/utilities + small utilities)
- Replace Tailwind CDN + REMOVE Flowbite CSS (keep Flowbite JS only)
- New base_tailwind.html (top navbar, responsive container, {%- block scripts -%})
- Port pages to Tailwind look/feel with wider content column:
  - index: single-column form + recent results, fullscreen spinner overlay, copy-UUID
  - result: sticky jump list, Tailwind tables/badges, Suspicious Scripts/Forms sections
  - viewer: Monaco-based code viewer in Tailwind card, actions (copy/wrap/raw)
  - ssl_tls macro: rewritten with Tailwind (details/summary for raw JSON)
- Dockerfile: add css-builder stage and copy built tw.css into /app/app/static
- Remove Flowbite stylesheet to avoid overrides; Flowbite JS loaded with defer

BREAKING CHANGE:
Legacy CSS classes/components (.card, .badge, etc.) are replaced by Tailwind utilities.
All templates now expect tw.css to be served from /static.
This commit is contained in:
2025-08-22 10:36:10 -05:00
parent 965c953e00
commit 469334d137
12 changed files with 842 additions and 1109 deletions

View File

@@ -1,25 +1,44 @@
{% extends "base.html" %}
{% block title %}Source Viewer{% endblock %}
{% block content %}
<div style="max-width:1100px;margin:0 auto;padding:1rem 1.25rem;">
<header style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<div>
<h2 style="margin:0;font-size:1.1rem;">Code Viewer</h2>
<div class="text-dim" style="font-size:0.9rem;">
<strong>File:</strong> <span id="fileName">{{ filename }}</span>
<div class="max-w-xl md:max-w-3xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl mx-auto space-y-4">
<section class="bg-card border border-gray-800 rounded-xl p-4">
<header class="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-lg font-semibold">Source Viewer</h2>
<div class="text-sm text-gray-400">
<strong>File:</strong> <span id="fileName" class="break-all">{{ filename }}</span>
</div>
</div>
</div>
<div style="display:flex;gap:.5rem;align-items:center;">
<button id="copyBtn" class="btn btn-sm">Copy</button>
<button id="wrapBtn" class="btn btn-sm">Toggle wrap</button>
<a id="openRaw" class="btn btn-sm" href="{{ raw_url }}" target="_blank" rel="noopener">Open raw</a>
<a id="downloadRaw" class="btn btn-sm" href="{{ raw_url }}" download>Download</a>
</div>
</header>
<div class="flex items-center gap-2">
<button id="copyBtn"
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
Copy
</button>
<button id="wrapBtn"
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
Toggle wrap
</button>
<a id="openRaw" href="{{ raw_url }}" target="_blank" rel="noopener"
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
Open raw
</a>
<a id="downloadRaw" href="{{ raw_url }}" download
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
Download
</a>
</div>
</header>
<div id="viewerStatus" class="text-dim" style="margin:.5rem 0 .75rem;"></div>
<div id="editor" style="height:72vh;border:1px solid #1f2a36;border-radius:8px;"></div>
<div id="viewerStatus" class="text-sm text-gray-400 mt-2 mb-3"></div>
<div id="editor" class="h-[72vh] border border-gray-800 rounded-lg"></div>
</section>
</div>
{% endblock %}
{% block scripts %}
<!-- Monaco AMD loader (no integrity to avoid mismatch) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.49.0/min/vs/loader.min.js"
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
@@ -36,13 +55,18 @@
if (!name) return 'plaintext';
const m = name.toLowerCase().match(/\.([a-z0-9]+)$/);
const ext = m ? m[1] : '';
const map = {js:'javascript',mjs:'javascript',cjs:'javascript',ts:'typescript',json:'json',
html:'html',htm:'html',css:'css',py:'python',sh:'shell',bash:'shell',
yml:'yaml',yaml:'yaml',md:'markdown',txt:'plaintext',log:'plaintext'};
const map = {
js:'javascript', mjs:'javascript', cjs:'javascript',
ts:'typescript', json:'json',
html:'html', htm:'html', css:'css',
py:'python', sh:'shell', bash:'shell',
yml:'yaml', yaml:'yaml',
md:'markdown', txt:'plaintext', log:'plaintext'
};
return map[ext] || 'plaintext';
}
// Wait until the AMD loader has defined window.require
// Wait until the AMD loader defines window.require
function waitForRequire(msLeft = 5000) {
return new Promise((resolve, reject) => {
const t0 = performance.now();
@@ -90,15 +114,23 @@
// Buttons
document.getElementById('copyBtn')?.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(editor.getValue()); statusEl.textContent = 'Copied.'; }
catch (e) { statusEl.textContent = 'Copy failed: ' + e; }
try {
await navigator.clipboard.writeText(editor.getValue());
statusEl.textContent = 'Copied.';
} catch (e) {
statusEl.textContent = 'Copy failed: ' + e;
}
});
document.getElementById('wrapBtn')?.addEventListener('click', () => {
const opts = editor.getRawOptions();
editor.updateOptions({ wordWrap: opts.wordWrap === 'on' ? 'off' : 'on' });
});
statusEl.textContent = (resp.ok ? '' : `Warning: HTTP ${resp.status}`) + (text.length ? '' : ' (empty file)');
// Status
const warn = resp.ok ? '' : `Warning: HTTP ${resp.status}`;
const empty = text.length ? '' : (warn ? ' · empty file' : 'Empty file');
statusEl.textContent = warn + (warn && empty ? '' : '') + (empty || '');
});
} catch (err) {
statusEl.textContent = 'Viewer error: ' + err.message;