Files
hireme/internal/ui/template_layout.go

536 lines
29 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<button id="tab-btn-preferences" onclick="switchTab('preferences')"
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">
⚙️ Preferences
</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>
<!-- Tab: Preferences -->
<div id="tab-content-preferences" class="hidden space-y-5">
{{ template "preferencesTab" . }}
</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 &amp; 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 &amp; 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', 'preferences'];
function switchTab(tab) {
TABS.forEach(t => {
const content = document.getElementById('tab-content-' + t);
if (content) content.classList.add('hidden');
const btn = document.getElementById('tab-btn-' + t);
if (btn) {
btn.classList.remove('text-indigo-400', 'border-indigo-500');
btn.classList.add('text-slate-400', 'border-transparent');
}
});
const activeContent = document.getElementById('tab-content-' + tab);
if (activeContent) activeContent.classList.remove('hidden');
const activeBtn = document.getElementById('tab-btn-' + tab);
if (activeBtn) {
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 &amp; All Posts';
});
}
// ── Maintenance Actions ──────────────────────────────────────────────
function runMaintenanceAction(url, daysInputId, protectCheckboxId, btn, originalText) {
const days = document.getElementById(daysInputId)?.value || '14';
const protect = document.getElementById(protectCheckboxId)?.checked ?? true;
const alertBox = document.getElementById('maintenance-alert');
btn.disabled = true;
btn.textContent = 'Processing…';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ days: days, protect: protect ? 'true' : 'false' })
})
.then(res => res.json())
.then(data => {
if (alertBox) {
alertBox.className = 'p-4 rounded-xl border text-xs font-semibold ' +
(data.status === 'success' ? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-300' : 'bg-rose-950/60 border-rose-500/40 text-rose-300');
alertBox.innerHTML = '<span>✓</span> ' + (data.message || 'Operation completed successfully.');
alertBox.classList.remove('hidden');
setTimeout(() => { alertBox.classList.add('hidden'); }, 8000);
}
// Refresh Jobs and Apply tables & stats
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 => {
if (alertBox) {
alertBox.className = 'p-4 rounded-xl border text-xs font-semibold bg-rose-950/60 border-rose-500/40 text-rose-300';
alertBox.innerHTML = '<span>✕</span> Error: ' + err.message;
alertBox.classList.remove('hidden');
}
})
.finally(() => {
btn.disabled = false;
btn.textContent = originalText;
});
}
// ── 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 | expired | pipeline
const textMatch = !text || rowText.includes(text);
let qualMatch;
switch (qual) {
case 'default': qualMatch = rowApp !== 'opted-out' && rowApp !== 'expired' && rowApp !== 'pipeline' && (rowQual === 'qualified' || rowQual === 'pending'); break;
case 'not-qualified': qualMatch = rowApp !== 'opted-out' && rowApp !== 'expired' && rowQual === 'not-qualified'; break;
case 'saved': qualMatch = rowApp === 'pipeline'; break;
case 'all': qualMatch = true; break;
case 'expired': qualMatch = rowApp === 'expired'; 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 }}`