477 lines
26 KiB
Go
477 lines
26 KiB
Go
package ui
|
||
|
||
|
||
const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
|
||
<html lang="en" class="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Hireme | Job Command Center</title>
|
||
<meta name="description" content="Automated job discovery, filtering, and application tracking powered by LLM analysis.">
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://unpkg.com/[email protected]"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
darkMode: 'class',
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
brand: {
|
||
50: '#eef2ff',
|
||
500: '#6366f1',
|
||
600: '#4f46e5',
|
||
700: '#4338ca',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||
body { font-family: 'Inter', sans-serif; }
|
||
.glass-panel {
|
||
background: rgba(30, 41, 59, 0.7);
|
||
backdrop-filter: blur(12px);
|
||
-webkit-backdrop-filter: blur(12px);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
|
||
.custom-scrollbar::-webkit-scrollbar-track { background: rgba(15, 23, 42, 0.6); }
|
||
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(99, 102, 241, 0.3); border-radius: 4px; }
|
||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(99, 102, 241, 0.6); }
|
||
/* Bottom bar clearance so content isn't hidden */
|
||
main { padding-bottom: 5rem; }
|
||
/* Sortable table headers */
|
||
th[data-sort] { cursor: pointer; user-select: none; }
|
||
th[data-sort]:hover { color: #a5b4fc; }
|
||
th[data-sort].sort-asc::after { content: ' ↑'; color: #818cf8; }
|
||
th[data-sort].sort-desc::after { content: ' ↓'; color: #818cf8; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-slate-950 text-slate-100 min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white">
|
||
|
||
<!-- Top Navigation Header -->
|
||
<header class="sticky top-0 z-30 glass-panel border-b border-slate-800">
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
|
||
<div class="flex items-center space-x-3">
|
||
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-violet-500 flex items-center justify-center shadow-lg shadow-indigo-500/30">
|
||
<span class="text-base font-black text-white">H</span>
|
||
</div>
|
||
<div>
|
||
<h1 class="text-base font-bold bg-gradient-to-r from-white via-indigo-200 to-indigo-400 bg-clip-text text-transparent">Hireme</h1>
|
||
<p class="text-[10px] text-slate-400 font-medium leading-none">Job Command Center</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick Stats -->
|
||
<div class="hidden md:flex items-center space-x-6 text-xs">
|
||
<div class="flex items-center space-x-1.5">
|
||
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
|
||
<span class="text-slate-400">To Review:</span>
|
||
<span class="font-bold text-emerald-400">{{ .ToReviewCount }}</span>
|
||
</div>
|
||
<div class="flex items-center space-x-1.5">
|
||
<span class="text-slate-400">In Pipeline:</span>
|
||
<span class="font-bold text-violet-400">{{ .InPipelineCount }}</span>
|
||
</div>
|
||
<div class="flex items-center space-x-1.5">
|
||
<span class="text-slate-400">Total Ingested:</span>
|
||
<span class="font-bold text-white">{{ .TotalIngestedCount }}</span>
|
||
</div>
|
||
<div class="flex items-center space-x-1.5 px-2 py-1 rounded-md bg-slate-900/60 border border-slate-800">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||
<span class="text-slate-400">LLM:</span>
|
||
<span class="font-semibold text-indigo-300 uppercase">{{ .LLMProvider }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Tab Bar -->
|
||
<div class="sticky top-14 z-20 bg-slate-950/90 backdrop-blur-md border-b border-slate-800">
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div class="flex space-x-0">
|
||
<button id="tab-btn-jobs" onclick="switchTab('jobs')"
|
||
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-indigo-400 border-indigo-500">
|
||
🎯 Jobs
|
||
</button>
|
||
<button id="tab-btn-apply" onclick="switchTab('apply')"
|
||
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-slate-400 border-transparent hover:text-slate-200">
|
||
📋 Apply
|
||
</button>
|
||
<button id="tab-btn-targets" onclick="switchTab('targets')"
|
||
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-slate-400 border-transparent hover:text-slate-200">
|
||
⚙️ Targets
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Workspace -->
|
||
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||
|
||
<!-- Tab: Jobs -->
|
||
<div id="tab-content-jobs">
|
||
{{ template "jobsTab" . }}
|
||
</div>
|
||
|
||
<!-- Tab: Apply (Table with pipeline indicator) -->
|
||
<div id="tab-content-apply" class="hidden">
|
||
{{ template "applyTab" . }}
|
||
</div>
|
||
|
||
<!-- Tab: Targets -->
|
||
<div id="tab-content-targets" class="hidden space-y-5">
|
||
{{ template "targetsTab" . }}
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<!-- Right Slide-Over Detail Panel -->
|
||
<div id="detail-modal" class="fixed inset-0 z-50 hidden" aria-modal="true">
|
||
<div class="absolute inset-0 bg-slate-950/70 backdrop-blur-sm" onclick="closeDetailModal()"></div>
|
||
<div id="detail-modal-body"
|
||
class="absolute top-0 right-0 w-full max-w-2xl bg-slate-900 border-l border-slate-800 h-full p-6 overflow-y-auto custom-scrollbar shadow-2xl transition-transform">
|
||
<!-- Dynamic HTMX content loaded here -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Target Confirmation Modal -->
|
||
<div id="delete-target-modal" class="fixed inset-0 z-50 hidden flex items-center justify-center p-4" aria-modal="true" role="dialog">
|
||
<div class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity" onclick="closeDeleteTargetModal()"></div>
|
||
<div class="relative w-full max-w-md bg-slate-900 border border-slate-700/80 rounded-2xl shadow-2xl shadow-black/80 p-6 text-slate-100 transform transition-all overflow-hidden z-10">
|
||
<div class="absolute -top-24 -left-24 w-48 h-48 bg-rose-500/15 rounded-full blur-3xl pointer-events-none"></div>
|
||
<div class="flex items-start gap-4">
|
||
<div class="w-10 h-10 rounded-xl bg-rose-500/10 border border-rose-500/30 flex items-center justify-center flex-shrink-0 text-xl shadow-inner">
|
||
⚠️
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<h3 class="text-base font-bold text-white leading-tight">Delete Target</h3>
|
||
<p class="text-xs text-slate-400 mt-1">This is a bulk operation that cannot be undone.</p>
|
||
</div>
|
||
<button type="button" onclick="closeDeleteTargetModal()" class="text-slate-400 hover:text-white text-lg p-1 rounded hover:bg-slate-800 transition-colors">✕</button>
|
||
</div>
|
||
|
||
<div class="mt-4 p-3 rounded-xl bg-slate-950/60 border border-slate-800">
|
||
<span class="text-[10px] uppercase font-bold text-slate-500 tracking-wider block">Target to remove:</span>
|
||
<span id="delete-target-name" class="text-sm font-semibold text-rose-300 block mt-0.5 break-words"></span>
|
||
</div>
|
||
|
||
<div class="mt-3 text-xs text-slate-300 space-y-1.5 leading-relaxed">
|
||
<p class="text-rose-400 font-medium">⚠️ What will be removed:</p>
|
||
<ul class="list-disc list-inside text-[11px] text-slate-400 space-y-1 pl-1">
|
||
<li>Target configuration and crawling settings</li>
|
||
<li><strong class="text-slate-200">All discovered job postings</strong> from this target</li>
|
||
<li>All associated AI qualification analyses & scores</li>
|
||
<li>Any <strong class="text-slate-200">in-pipeline applications</strong> tracked for these jobs</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="mt-6 flex items-center justify-end gap-3">
|
||
<button type="button"
|
||
onclick="closeDeleteTargetModal()"
|
||
class="px-4 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 transition-colors">
|
||
Cancel
|
||
</button>
|
||
<button id="delete-target-confirm-btn"
|
||
type="button"
|
||
class="px-4 py-2 text-xs font-semibold text-white bg-rose-600 hover:bg-rose-500 rounded-lg shadow-lg shadow-rose-500/25 transition-all flex items-center gap-1.5 active:scale-95">
|
||
<span>🗑️</span> Delete Target & All Posts
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fixed Bottom Status & Action Bar -->
|
||
<div id="bottom-bar"
|
||
class="fixed bottom-0 left-0 right-0 z-40 bg-slate-900/95 backdrop-blur-md border-t border-slate-700/60 shadow-2xl shadow-black/50">
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between gap-4">
|
||
|
||
<!-- Left: Worker + Queue Status (auto-refreshed) -->
|
||
<div id="status-bar-left" class="flex items-center gap-4 text-xs">
|
||
<div id="worker-status-badge" class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-slate-800/80 border border-slate-700">
|
||
<span id="worker-dot" class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||
<span class="text-slate-300">Workers:</span>
|
||
<span id="worker-label" class="font-bold text-emerald-400 uppercase">Running</span>
|
||
</div>
|
||
<div class="flex items-center gap-3 text-slate-400">
|
||
<span>Pending: <strong id="q-pending" class="text-amber-400">–</strong></span>
|
||
<span>Running: <strong id="q-running" class="text-sky-400">–</strong></span>
|
||
<span>Failed: <strong id="q-failed" class="text-rose-400">–</strong></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Action Buttons -->
|
||
<div class="flex items-center gap-2">
|
||
<button id="btn-discover"
|
||
onclick="triggerAction('/api/tasks/discover', this, '🔍 Discover', 'Discovering…')"
|
||
class="action-btn px-3 py-1.5 text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500 rounded-lg transition-all active:scale-95 shadow-md shadow-indigo-500/20">
|
||
🔍 Discover
|
||
</button>
|
||
<button id="btn-reprocess"
|
||
onclick="triggerAction('/api/tasks/reprocess', this, '🔄 Reprocess', 'Reprocessing…')"
|
||
class="action-btn px-3 py-1.5 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-500 rounded-lg transition-all active:scale-95 shadow-md shadow-amber-500/20">
|
||
🔄 Reprocess
|
||
</button>
|
||
<button id="btn-clean"
|
||
onclick="triggerAction('/api/tasks/clean', this, '🗑️ Clean Queue', 'Cleaning…')"
|
||
class="action-btn px-3 py-1.5 text-xs font-semibold text-white bg-rose-700 hover:bg-rose-600 rounded-lg transition-all active:scale-95 shadow-md shadow-rose-500/20">
|
||
🗑️ Clean
|
||
</button>
|
||
<button id="btn-toggle"
|
||
onclick="toggleWorkers(this)"
|
||
class="action-btn px-3 py-1.5 text-xs font-semibold text-white bg-slate-700 hover:bg-slate-600 rounded-lg transition-all active:scale-95 border border-slate-600">
|
||
⏸ Pause Workers
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── Tab Routing ──────────────────────────────────────────────────────
|
||
const TABS = ['jobs', 'apply', 'targets'];
|
||
|
||
function switchTab(tab) {
|
||
TABS.forEach(t => {
|
||
document.getElementById('tab-content-' + t).classList.add('hidden');
|
||
const btn = document.getElementById('tab-btn-' + t);
|
||
btn.classList.remove('text-indigo-400', 'border-indigo-500');
|
||
btn.classList.add('text-slate-400', 'border-transparent');
|
||
});
|
||
document.getElementById('tab-content-' + tab).classList.remove('hidden');
|
||
const activeBtn = document.getElementById('tab-btn-' + tab);
|
||
activeBtn.classList.remove('text-slate-400', 'border-transparent');
|
||
activeBtn.classList.add('text-indigo-400', 'border-indigo-500');
|
||
history.replaceState(null, '', '#' + tab);
|
||
}
|
||
|
||
// Restore tab from URL hash on load
|
||
(function() {
|
||
const hash = location.hash.replace('#', '');
|
||
if (TABS.includes(hash)) switchTab(hash);
|
||
})();
|
||
|
||
// ── Detail Modal ─────────────────────────────────────────────────────
|
||
function openDetailModal() {
|
||
document.getElementById('detail-modal').classList.remove('hidden');
|
||
}
|
||
function closeDetailModal() {
|
||
document.getElementById('detail-modal').classList.add('hidden');
|
||
}
|
||
|
||
// ── Delete Target Modal ──────────────────────────────────────────────
|
||
let currentDeleteTargetId = null;
|
||
|
||
function openDeleteTargetModal(btn) {
|
||
const id = btn.dataset.targetId;
|
||
const name = btn.dataset.targetName || 'this target';
|
||
currentDeleteTargetId = id;
|
||
document.getElementById('delete-target-name').textContent = name;
|
||
const confirmBtn = document.getElementById('delete-target-confirm-btn');
|
||
confirmBtn.onclick = () => confirmDeleteTarget(id);
|
||
document.getElementById('delete-target-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function closeDeleteTargetModal() {
|
||
document.getElementById('delete-target-modal').classList.add('hidden');
|
||
currentDeleteTargetId = null;
|
||
}
|
||
|
||
function confirmDeleteTarget(id) {
|
||
const confirmBtn = document.getElementById('delete-target-confirm-btn');
|
||
confirmBtn.disabled = true;
|
||
confirmBtn.textContent = 'Deleting…';
|
||
|
||
fetch('/targets/' + id + '/delete', { method: 'POST' })
|
||
.then(res => {
|
||
if (!res.ok) throw new Error('Failed to delete target');
|
||
return res.text();
|
||
})
|
||
.then(html => {
|
||
const targetContainer = document.getElementById('targets-container');
|
||
if (targetContainer) targetContainer.innerHTML = html;
|
||
closeDeleteTargetModal();
|
||
// Refresh Jobs and Apply tables & stats since associated jobs were deleted
|
||
const jobsContainer = document.getElementById('jobs-container');
|
||
if (jobsContainer) htmx.trigger(jobsContainer, 'refresh');
|
||
const applyContainer = document.getElementById('apply-container');
|
||
if (applyContainer) htmx.trigger(applyContainer, 'refresh');
|
||
refreshStatus();
|
||
})
|
||
.catch(err => {
|
||
alert('Error deleting target: ' + err.message);
|
||
})
|
||
.finally(() => {
|
||
confirmBtn.disabled = false;
|
||
confirmBtn.innerHTML = '<span>🗑️</span> Delete Target & All Posts';
|
||
});
|
||
}
|
||
|
||
// ── Bottom Bar: Status Polling ────────────────────────────────────────
|
||
function refreshStatus() {
|
||
fetch('/api/status')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const isRunning = data.worker_status === 'running';
|
||
document.getElementById('worker-dot').className =
|
||
'w-2 h-2 rounded-full ' + (isRunning ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500');
|
||
document.getElementById('worker-label').textContent = isRunning ? 'Running' : 'Paused';
|
||
document.getElementById('worker-label').className =
|
||
'font-bold uppercase ' + (isRunning ? 'text-emerald-400' : 'text-slate-400');
|
||
document.getElementById('btn-toggle').textContent =
|
||
isRunning ? '⏸ Pause Workers' : '▶ Start Workers';
|
||
if (data.queue) {
|
||
document.getElementById('q-pending').textContent = data.queue.pending;
|
||
document.getElementById('q-running').textContent = data.queue.running;
|
||
document.getElementById('q-failed').textContent = data.queue.failed;
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
refreshStatus();
|
||
setInterval(refreshStatus, 3000);
|
||
|
||
// ── Action Button Helper ──────────────────────────────────────────────
|
||
function triggerAction(url, btn, originalLabel, loadingLabel) {
|
||
btn.disabled = true;
|
||
btn.textContent = loadingLabel;
|
||
fetch(url, { method: 'POST' })
|
||
.then(() => { refreshStatus(); })
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
setTimeout(() => {
|
||
btn.disabled = false;
|
||
btn.textContent = originalLabel;
|
||
}, 1200);
|
||
});
|
||
}
|
||
|
||
function toggleWorkers(btn) {
|
||
btn.disabled = true;
|
||
fetch('/api/tasks/toggle', { method: 'POST' })
|
||
.then(() => { refreshStatus(); })
|
||
.catch(() => {})
|
||
.finally(() => { btn.disabled = false; });
|
||
}
|
||
|
||
// ── Generic Table Sort/Filter Helpers ─────────────────────────────────
|
||
function initSortableTable(tableId) {
|
||
const table = document.getElementById(tableId);
|
||
if (!table) return;
|
||
table.querySelectorAll('th[data-sort]').forEach(th => {
|
||
th.removeEventListener('click', th._sortHandler);
|
||
th._sortHandler = () => sortTable(table, th);
|
||
th.addEventListener('click', th._sortHandler);
|
||
});
|
||
}
|
||
|
||
function sortTable(table, th) {
|
||
const col = Array.from(th.parentElement.children).indexOf(th);
|
||
const asc = !th.classList.contains('sort-asc');
|
||
table.querySelectorAll('th[data-sort]').forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||
th.classList.add(asc ? 'sort-asc' : 'sort-desc');
|
||
const tbody = table.querySelector('tbody');
|
||
const rows = Array.from(tbody.querySelectorAll('tr[data-row]'));
|
||
rows.sort((a, b) => {
|
||
const aVal = a.cells[col]?.dataset.sortVal || a.cells[col]?.textContent.trim() || '';
|
||
const bVal = b.cells[col]?.dataset.sortVal || b.cells[col]?.textContent.trim() || '';
|
||
const aNum = parseFloat(aVal), bNum = parseFloat(bVal);
|
||
if (!isNaN(aNum) && !isNaN(bNum)) return asc ? aNum - bNum : bNum - aNum;
|
||
return asc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
||
});
|
||
rows.forEach(r => tbody.appendChild(r));
|
||
}
|
||
|
||
// ── Job Table Filter ──────────────────────────────────────────────────
|
||
function filterJobTable() {
|
||
const table = document.getElementById('jobs-table');
|
||
if (!table) return;
|
||
const text = (document.getElementById('job-filter-text')?.value || '').toLowerCase();
|
||
const qual = document.getElementById('job-filter-qual')?.value || 'default';
|
||
table.querySelectorAll('tr[data-row]').forEach(row => {
|
||
const rowText = row.textContent.toLowerCase();
|
||
const rowQual = row.dataset.qual || ''; // qualified | not-qualified | pending
|
||
const rowApp = row.dataset.app || 'none'; // none | opted-out | pipeline
|
||
const textMatch = !text || rowText.includes(text);
|
||
let qualMatch;
|
||
switch (qual) {
|
||
case 'default': qualMatch = rowApp !== 'opted-out' && rowApp !== 'pipeline' && (rowQual === 'qualified' || rowQual === 'pending'); break;
|
||
case 'not-qualified': qualMatch = rowApp !== 'opted-out' && rowQual === 'not-qualified'; break;
|
||
case 'saved': qualMatch = rowApp === 'pipeline'; break;
|
||
case 'all': qualMatch = rowApp !== 'opted-out'; break;
|
||
case 'opted-out': qualMatch = rowApp === 'opted-out'; break;
|
||
default: qualMatch = true;
|
||
}
|
||
row.style.display = (textMatch && qualMatch) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function initJobTable() {
|
||
initSortableTable('jobs-table');
|
||
const filterInput = document.getElementById('job-filter-text');
|
||
const filterSelect = document.getElementById('job-filter-qual');
|
||
if (filterInput) filterInput.addEventListener('input', filterJobTable);
|
||
if (filterSelect) filterSelect.addEventListener('change', filterJobTable);
|
||
// Re-apply current filter immediately after table is rebuilt
|
||
filterJobTable();
|
||
}
|
||
|
||
// ── Apply Table Filter ────────────────────────────────────────────────
|
||
function filterApplyTable() {
|
||
const table = document.getElementById('apply-table');
|
||
if (!table) return;
|
||
const text = (document.getElementById('apply-filter-text')?.value || '').toLowerCase();
|
||
const status = document.getElementById('apply-filter-status')?.value || 'active';
|
||
table.querySelectorAll('tr[data-row]').forEach(row => {
|
||
const rowText = row.textContent.toLowerCase();
|
||
const rowStatus = row.dataset.appStatus || '';
|
||
const textMatch = !text || rowText.includes(text);
|
||
const ACTIVE = ['SAVED','APPLIED','INTERVIEW','OFFER'];
|
||
let statusMatch;
|
||
if (status === 'active') statusMatch = ACTIVE.includes(rowStatus);
|
||
else if (status === 'all') statusMatch = true;
|
||
else statusMatch = rowStatus === status;
|
||
row.style.display = (textMatch && statusMatch) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function initApplyTable() {
|
||
initSortableTable('apply-table');
|
||
const filterInput = document.getElementById('apply-filter-text');
|
||
const filterSelect = document.getElementById('apply-filter-status');
|
||
if (filterInput) filterInput.addEventListener('input', filterApplyTable);
|
||
if (filterSelect) filterSelect.addEventListener('change', filterApplyTable);
|
||
filterApplyTable();
|
||
}
|
||
|
||
// ── Init & HTMX Hooks ─────────────────────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initJobTable();
|
||
initApplyTable();
|
||
// Auto-refresh job list every 12s to pick up newly processed jobs
|
||
setInterval(() => {
|
||
htmx.trigger(document.getElementById('jobs-container'), 'refresh');
|
||
}, 12000);
|
||
});
|
||
|
||
// Re-init tables after any HTMX swap so filters & sorting stay active
|
||
document.addEventListener('htmx:afterSwap', (e) => {
|
||
if (!e.target) return;
|
||
if (e.target.id === 'jobs-container' || e.target.closest?.('#jobs-container'))
|
||
initJobTable();
|
||
if (e.target.id === 'apply-container' || e.target.closest?.('#apply-container'))
|
||
initApplyTable();
|
||
});
|
||
|
||
// Dismiss modals on Escape key
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
closeDetailModal();
|
||
closeDeleteTargetModal();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>{{ end }}`
|