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:
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user