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,159 +1,201 @@
{% extends 'base.html' %}
{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
<!-- Single-column stack, centered and comfortably narrow -->
<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">
<!-- Analysis Form -->
<form id="analyze-form" method="post" action="{{ url_for('main.analyze') }}" class="card">
<h2>Analyze a URL</h2>
<label for="url">Enter a URL to analyze</label>
<input id="url" name="url" type="url" placeholder="https://example.com" required />
<!-- Start a New Analysis -->
<section class="bg-card border border-gray-800 rounded-xl p-4">
<h2 class="text-lg font-semibold mb-3">Start a New Analysis</h2>
<!-- toggle for pulling ssl/cert data -->
<label class="checkbox-row">
<input type="checkbox" name="fetch_ssl" value="1">
Pull SSL/TLS data (crt.sh + version probe) - Warning, crt.sh can be <b>very slow</b> at times
</label>
<form id="analyze-form" action="{{ url_for('main.analyze') }}" method="post" class="space-y-3">
<div>
<label for="url" class="block text-sm text-gray-400 mb-1">Target URL or Domain</label>
<input
id="url"
name="url"
type="text"
required
class="w-full bg-[#0b0f14] border border-gray-700 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://example.com"
>
</div>
<button type="submit">Analyze</button>
</form>
<!-- Recent Results (optional; shown only if recent_results provided) -->
{% if recent_results %}
<div class="card" id="recent-results">
<h2>Recent Results</h2>
<table class="results-table">
<thead>
<tr>
<th>Timestamp</th>
<th>URL</th>
<th>UUID</th>
</tr>
</thead>
<tbody>
{% for r in recent_results %}
<tr>
<td class="timestamp">
{% if r.timestamp %}
{{ r.timestamp }}
{% else %}
N/A
{% endif %}
</td>
<td class="url">
<a href="{{ url_for('main.view_result', run_uuid=r.uuid) }}">
{{ r.final_url or r.submitted_url }}
</a>
</td>
<td class="uuid">
<code id="uuid-{{ loop.index }}">{{ r.uuid }}</code>
<button
type="button"
class="copy-btn"
data-target="uuid-{{ loop.index }}">
📋
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-60"
>
<span id="btn-spinner" class="hidden animate-spin inline-block h-4 w-4 rounded-full border-2 border-white/40 border-t-white"></span>
Analyse
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- toggle for pulling ssl/cert data -->
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="fetch_ssl"
value="1"
class="rounded border-gray-600 bg-[#0b0f14]"
>
<span>
Pull SSL/TLS data (crt.sh + version probe)
<span class="text-gray-400">— crt.sh can be <b>very slow</b> at times</span>
</span>
</label>
</div>
</form>
</section>
<!-- Spinner Modal -->
<div id="spinner-modal" style="
display:none;
opacity:0;
position:fixed;
top:0;
left:0;
width:100%;
height:100%;
background:rgba(0,0,0,0.7);
color:#fff;
font-size:1.5rem;
text-align:center;
padding-top:20%;
z-index:9999;
transition: opacity 0.3s ease;
">
<div>
<div class="loader" style="
border: 8px solid #f3f3f3;
border-top: 8px solid #1a2535;
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem auto;
"></div>
Analyzing website…
<!-- Recent Results -->
{% if recent_results %}
<section class="bg-card border border-gray-800 rounded-xl p-4" id="recent-results">
<h2 class="text-base font-semibold mb-3">Recent Results</h2>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-gray-400 border-b border-gray-800">
<tr>
<th class="text-left py-2 pr-4">Timestamp</th>
<th class="text-left py-2 pr-4">URL</th>
<th class="text-left py-2 pr-4">UUID</th>
</tr>
</thead>
<tbody>
{% for r in recent_results %}
<tr class="border-b border-gray-900">
<td class="py-2 pr-4 whitespace-nowrap">
{% if r.timestamp %}{{ r.timestamp }}{% else %}N/A{% endif %}
</td>
<td class="py-2 pr-4">
<a class="hover:text-blue-400" href="{{ url_for('main.view_result', run_uuid=r.uuid) }}">
{{ r.final_url or r.submitted_url }}
</a>
</td>
<td class="py-2 pr-4">
<div class="flex items-center gap-2">
<code id="uuid-{{ loop.index }}" class="text-gray-300">{{ r.uuid }}</code>
<button
type="button"
class="copy-btn inline-flex items-center justify-center rounded-md border border-gray-700 px-2 py-1 text-xs hover:bg-gray-800"
data-target="uuid-{{ loop.index }}"
title="Copy UUID"
>
📋
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% else %}
<section class="bg-card border border-gray-800 rounded-xl p-4">
<h2 class="text-base font-semibold mb-2">Recent Results</h2>
<p class="text-sm text-gray-500">No recent scans.</p>
</section>
{% endif %}
</div>
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<!-- Fullscreen spinner overlay -->
<div
id="spinner-modal"
class="fixed inset-0 hidden opacity-0 transition-opacity duration-300 bg-black/70 z-50"
role="dialog"
aria-modal="true"
aria-label="Analyzing website"
>
<div class="min-h-screen flex items-center justify-center p-4 text-center">
<div class="bg-card border border-gray-800 rounded-xl px-6 py-5 shadow">
<div class="mx-auto mb-3 h-12 w-12 rounded-full border-4 border-white/30 border-t-white animate-spin"></div>
<div class="text-base">Analyzing website…</div>
</div>
</div>
</div>
{% endblock %}
{% block page_js %}
{% block scripts %}
<script>
const form = document.getElementById('analyze-form');
const modal = document.getElementById('spinner-modal');
function showModal() {
modal.style.display = 'block';
requestAnimationFrame(() => {
modal.style.opacity = '1';
});
/**
* Show the fullscreen spinner overlay.
*/
function showSpinner() {
const modal = document.getElementById('spinner-modal');
if (!modal) return;
modal.classList.remove('hidden');
// allow reflow so opacity transition runs
requestAnimationFrame(() => modal.classList.remove('opacity-0'));
}
function hideModal() {
modal.style.opacity = '0';
/**
* Hide the fullscreen spinner overlay.
*/
function hideSpinner() {
const modal = document.getElementById('spinner-modal');
if (!modal) return;
modal.classList.add('opacity-0');
modal.addEventListener('transitionend', () => {
modal.style.display = 'none';
modal.classList.add('hidden');
}, { once: true });
}
// Hide spinner on initial load / back navigation
window.addEventListener('pageshow', () => {
modal.style.opacity = '0';
modal.style.display = 'none';
});
/**
* Initialize form submit handling:
* - shows overlay spinner
* - disables submit button
* - shows small spinner inside button
* - lets the browser continue with POST
*/
(function initAnalyzeForm() {
const form = document.getElementById('analyze-form');
if (!form) return;
form.addEventListener('submit', (e) => {
showModal();
// Prevent double submission
form.querySelector('button').disabled = true;
const submitBtn = form.querySelector('button[type="submit"]');
const btnSpinner = document.getElementById('btn-spinner');
// Allow browser to render the modal before submitting
requestAnimationFrame(() => form.submit());
e.preventDefault();
});
</script>
// Hide spinner overlay if arriving from bfcache/back
window.addEventListener('pageshow', () => {
hideSpinner();
if (submitBtn) submitBtn.disabled = false;
if (btnSpinner) btnSpinner.classList.add('hidden');
});
<script>
document.addEventListener('DOMContentLoaded', () => {
const buttons = document.querySelectorAll('.copy-btn');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
form.addEventListener('submit', (e) => {
// prevent immediate submit so UI can paint spinner first
e.preventDefault();
if (submitBtn) submitBtn.disabled = true;
if (btnSpinner) btnSpinner.classList.remove('hidden');
showSpinner();
// allow a frame so spinner paints, then submit
requestAnimationFrame(() => form.submit());
});
})();
/**
* Copy UUID buttons
*/
(function initCopyButtons() {
document.querySelectorAll('.copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const targetId = btn.getAttribute('data-target');
const uuidText = document.getElementById(targetId).innerText;
const el = document.getElementById(targetId);
if (!el) return;
navigator.clipboard.writeText(uuidText).then(() => {
// Give quick feedback
try {
await navigator.clipboard.writeText(el.textContent.trim());
const prev = btn.textContent;
btn.textContent = '✅';
setTimeout(() => { btn.textContent = '📋'; }, 1500);
}).catch(err => {
setTimeout(() => { btn.textContent = prev; }, 1200);
} catch (err) {
console.error('Failed to copy UUID:', err);
});
}
});
});
});
})();
</script>
{% endblock %}