705 lines
33 KiB
Go
705 lines
33 KiB
Go
package ui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"html/template"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hireme/hireme/internal/db"
|
|
)
|
|
|
|
var funcMap = template.FuncMap{
|
|
"formatTime": func(t *time.Time) string {
|
|
if t == nil {
|
|
return "Never"
|
|
}
|
|
return t.Format("Jan 02, 2006 15:04")
|
|
},
|
|
"formatDate": func(t time.Time) string {
|
|
return t.Format("Jan 02, 2006")
|
|
},
|
|
"parseJSON": func(jsonStr string) []string {
|
|
if jsonStr == "" {
|
|
return nil
|
|
}
|
|
var list []string
|
|
_ = json.Unmarshal([]byte(jsonStr), &list)
|
|
return list
|
|
},
|
|
"scoreBadgeClass": func(score float64) string {
|
|
if score >= 50 {
|
|
return "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
|
} else if score >= 20 {
|
|
return "bg-amber-500/20 text-amber-400 border-amber-500/30"
|
|
}
|
|
return "bg-slate-700/50 text-slate-400 border-slate-600/30"
|
|
},
|
|
"platformIcon": func(platform string) string {
|
|
p := strings.ToLower(platform)
|
|
if strings.Contains(p, "linkedin") {
|
|
return "💼 LinkedIn"
|
|
} else if strings.Contains(p, "google") {
|
|
return "🔍 Google Jobs"
|
|
} else if strings.Contains(p, "greenhouse") {
|
|
return "🌿 Greenhouse"
|
|
} else if strings.Contains(p, "lever") {
|
|
return "⚙️ Lever"
|
|
}
|
|
return "🌐 Web Target"
|
|
},
|
|
}
|
|
|
|
type PageData struct {
|
|
Targets []db.SearchTarget
|
|
Jobs []db.JobWithAnalysis
|
|
Applications []db.JobWithAnalysis
|
|
ActiveTab string
|
|
LLMProvider string
|
|
QualifiedJobsCount int
|
|
TotalIngestedCount int
|
|
ActiveApplications int
|
|
}
|
|
|
|
var (
|
|
rootTemplate *template.Template
|
|
)
|
|
|
|
func init() {
|
|
var err error
|
|
rootTemplate = template.New("root").Funcs(funcMap)
|
|
rootTemplate, err = rootTemplate.Parse(mainLayoutHTML)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
rootTemplate, err = rootTemplate.Parse(targetListHTML)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
rootTemplate, err = rootTemplate.Parse(jobListHTML)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
rootTemplate, err = rootTemplate.Parse(jobDetailHTML)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
rootTemplate, err = rootTemplate.Parse(kanbanBoardHTML)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
rootTemplate, err = rootTemplate.Parse(kanbanCardHTML)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func RenderPage(w io.Writer, data PageData) error {
|
|
return rootTemplate.ExecuteTemplate(w, "page", data)
|
|
}
|
|
|
|
func RenderTargetList(w io.Writer, targets []db.SearchTarget) error {
|
|
return rootTemplate.ExecuteTemplate(w, "targets", targets)
|
|
}
|
|
|
|
func RenderJobList(w io.Writer, jobs []db.JobWithAnalysis) error {
|
|
return rootTemplate.ExecuteTemplate(w, "jobs", jobs)
|
|
}
|
|
|
|
func RenderJobDetail(w io.Writer, job *db.JobWithAnalysis) error {
|
|
return rootTemplate.ExecuteTemplate(w, "jobDetail", job)
|
|
}
|
|
|
|
func RenderKanbanBoard(w io.Writer, apps []db.JobWithAnalysis) error {
|
|
return rootTemplate.ExecuteTemplate(w, "kanban", apps)
|
|
}
|
|
|
|
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>
|
|
<!-- Tailwind CSS CDN -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<!-- HTMX -->
|
|
<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); }
|
|
</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-16 flex items-center justify-between">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-9 h-9 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-xl font-black text-white">H</span>
|
|
</div>
|
|
<div>
|
|
<h1 class="text-lg font-bold bg-gradient-to-r from-white via-indigo-200 to-indigo-400 bg-clip-text text-transparent">Hireme</h1>
|
|
<p class="text-xs text-slate-400 font-medium">Job Application Command Center</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Header Quick Stats & LLM Indicator -->
|
|
<div class="flex items-center space-x-4">
|
|
<div class="hidden md:flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-slate-900/60 border border-slate-800 text-xs">
|
|
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
|
<span class="text-slate-400">LLM Provider:</span>
|
|
<span class="font-semibold text-indigo-300 uppercase">{{ .LLMProvider }}</span>
|
|
</div>
|
|
|
|
<div class="flex items-center space-x-2">
|
|
<button hx-post="/api/tasks/discover"
|
|
hx-swap="none"
|
|
hx-on::after-request="location.reload()"
|
|
class="relative inline-flex items-center justify-center px-3 py-1.5 text-xs font-semibold text-white transition-all bg-indigo-600 rounded-lg shadow-md hover:bg-indigo-500 active:scale-95 shadow-indigo-500/25">
|
|
<span>🔍 Discover Jobs</span>
|
|
</button>
|
|
<button hx-post="/api/tasks/clean"
|
|
hx-swap="none"
|
|
hx-on::after-request="location.reload()"
|
|
class="relative inline-flex items-center justify-center px-3 py-1.5 text-xs font-semibold text-white transition-all bg-red-600 rounded-lg shadow-md hover:bg-red-500 active:scale-95 shadow-red-500/25">
|
|
<span>🗑️ Clean Queue</span>
|
|
</button>
|
|
<button hx-post="/api/tasks/toggle"
|
|
hx-swap="none"
|
|
hx-on::after-request="location.reload()"
|
|
class="relative inline-flex items-center justify-center px-3 py-1.5 text-xs font-semibold text-white transition-all bg-emerald-600 rounded-lg shadow-md hover:bg-emerald-500 active:scale-95 shadow-emerald-500/25">
|
|
<span>⚙️ Toggle Workers</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Workspace Container -->
|
|
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
|
|
|
<!-- Metrics Stats Cards -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
|
|
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-indigo-500/10 rounded-full blur-xl group-hover:bg-indigo-500/20 transition-all"></div>
|
|
<p class="text-xs font-medium text-slate-400">Total Search Targets</p>
|
|
<h3 class="text-2xl font-bold text-white mt-1">{{ len .Targets }}</h3>
|
|
<span class="text-[10px] text-slate-500 mt-2 block">Active Crawler Pipelines</span>
|
|
</div>
|
|
|
|
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
|
|
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-emerald-500/10 rounded-full blur-xl group-hover:bg-emerald-500/20 transition-all"></div>
|
|
<p class="text-xs font-medium text-slate-400">Qualified Jobs Matched</p>
|
|
<h3 class="text-2xl font-bold text-emerald-400 mt-1">{{ .QualifiedJobsCount }}</h3>
|
|
<span class="text-[10px] text-emerald-500/80 mt-2 block">Passed Keyword & Score Filter</span>
|
|
</div>
|
|
|
|
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
|
|
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-amber-500/10 rounded-full blur-xl group-hover:bg-amber-500/20 transition-all"></div>
|
|
<p class="text-xs font-medium text-slate-400">Total Ingested Postings</p>
|
|
<h3 class="text-2xl font-bold text-amber-400 mt-1">{{ .TotalIngestedCount }}</h3>
|
|
<span class="text-[10px] text-slate-500 mt-2 block">Deduplicated Raw Crawls</span>
|
|
</div>
|
|
|
|
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
|
|
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-violet-500/10 rounded-full blur-xl group-hover:bg-violet-500/20 transition-all"></div>
|
|
<p class="text-xs font-medium text-slate-400">Active Applications</p>
|
|
<h3 class="text-2xl font-bold text-violet-400 mt-1">{{ .ActiveApplications }}</h3>
|
|
<span class="text-[10px] text-slate-500 mt-2 block">Tracked in Kanban Pipeline</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabbed View Container -->
|
|
<div class="glass-panel rounded-2xl border border-slate-800 overflow-hidden shadow-xl">
|
|
<!-- Tabs Bar -->
|
|
<div class="flex items-center border-b border-slate-800 px-6 pt-4 space-x-6">
|
|
<button onclick="switchTab('triage')" id="tab-btn-triage"
|
|
class="pb-3 text-sm font-semibold text-indigo-400 border-b-2 border-indigo-500 transition-colors">
|
|
🎯 Job Triage & Matches
|
|
</button>
|
|
<button onclick="switchTab('kanban')" id="tab-btn-kanban"
|
|
class="pb-3 text-sm font-semibold text-slate-400 border-b-2 border-transparent hover:text-slate-200 transition-colors">
|
|
📋 Application Kanban
|
|
</button>
|
|
<button onclick="switchTab('targets')" id="tab-btn-targets"
|
|
class="pb-3 text-sm font-semibold text-slate-400 border-b-2 border-transparent hover:text-slate-200 transition-colors">
|
|
⚙️ Crawler Targets
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tab Content 1: Job Triage List -->
|
|
<div id="tab-content-triage" class="p-6">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 class="text-base font-bold text-white">Qualified Job Opportunities</h2>
|
|
<p class="text-xs text-slate-400">Ranked by rule score + cached LLM skill gap analysis</p>
|
|
</div>
|
|
<button hx-get="/jobs" hx-target="#jobs-container" class="text-xs text-indigo-400 hover:underline">
|
|
🔄 Refresh List
|
|
</button>
|
|
</div>
|
|
<div id="jobs-container">
|
|
{{ template "jobs" .Jobs }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab Content 2: Application Kanban -->
|
|
<div id="tab-content-kanban" class="p-6 hidden">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 class="text-base font-bold text-white">Job Application Tracking</h2>
|
|
<p class="text-xs text-slate-400">Manage saved, applied, interviewing, and offer states</p>
|
|
</div>
|
|
</div>
|
|
<div id="kanban-container">
|
|
{{ template "kanban" .Applications }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab Content 3: Target Configurations -->
|
|
<div id="tab-content-targets" class="p-6 hidden space-y-6">
|
|
<!-- Add Target Form -->
|
|
<div class="p-5 rounded-xl bg-slate-900/60 border border-slate-800">
|
|
<h3 class="text-sm font-bold text-white mb-3">Add Target Search URL</h3>
|
|
<form hx-post="/targets" hx-target="#targets-container" class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
|
<input type="text" name="name" placeholder="Target Name (e.g. Go Backend SF)" required
|
|
class="px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
|
|
<input type="url" name="target_url" placeholder="Target URL" required
|
|
class="px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
|
|
<select name="platform" class="px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
|
|
<option value="linkedin">LinkedIn</option>
|
|
<option value="google_jobs">Google Jobs</option>
|
|
<option value="greenhouse">Greenhouse</option>
|
|
<option value="lever">Lever</option>
|
|
<option value="generic">Generic Web</option>
|
|
</select>
|
|
<button type="submit" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold text-white rounded-lg shadow-md transition-all">
|
|
+ Add Target
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Targets Table -->
|
|
<div id="targets-container">
|
|
{{ template "targets" .Targets }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<!-- Slide-Over Detail Modal -->
|
|
<div id="detail-modal" class="fixed inset-0 z-50 hidden bg-slate-950/80 backdrop-blur-sm flex justify-end">
|
|
<div id="detail-modal-body" class="w-full max-w-2xl bg-slate-900 border-l border-slate-800 h-full p-6 overflow-y-auto custom-scrollbar shadow-2xl">
|
|
<!-- Dynamic HTMX content loaded here -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab Switching Script -->
|
|
<script>
|
|
function switchTab(tab) {
|
|
['triage', 'kanban', 'targets'].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');
|
|
}
|
|
|
|
function closeDetailModal() {
|
|
document.getElementById('detail-modal').classList.add('hidden');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>{{ end }}`
|
|
|
|
const targetListHTML = `{{ define "targets" }}
|
|
<div class="overflow-x-auto rounded-xl border border-slate-800">
|
|
<table class="w-full text-left text-xs text-slate-300">
|
|
<thead class="bg-slate-900/80 text-slate-400 uppercase font-semibold text-[10px]">
|
|
<tr>
|
|
<th class="px-4 py-3">Platform</th>
|
|
<th class="px-4 py-3">Name</th>
|
|
<th class="px-4 py-3">Target URL</th>
|
|
<th class="px-4 py-3">Last Crawled</th>
|
|
<th class="px-4 py-3">Status</th>
|
|
<th class="px-4 py-3 text-right">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
|
|
{{ range . }}
|
|
<tr class="hover:bg-slate-800/40 transition-colors">
|
|
<td class="px-4 py-3 font-medium text-indigo-300">{{ platformIcon .Platform }}</td>
|
|
<td class="px-4 py-3 font-semibold text-white">{{ .Name }}</td>
|
|
<td class="px-4 py-3 max-w-xs truncate text-slate-400">
|
|
<a href="{{ .TargetURL }}" target="_blank" class="hover:underline text-indigo-400">{{ .TargetURL }}</a>
|
|
</td>
|
|
<td class="px-4 py-3 text-slate-400">{{ formatTime .LastCrawledAt }}</td>
|
|
<td class="px-4 py-3">
|
|
{{ if .IsActive }}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">Active</span>
|
|
{{ else }}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-slate-700/50 text-slate-400 border border-slate-600/30">Paused</span>
|
|
{{ end }}
|
|
</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<button hx-post="/targets/{{ .ID }}/toggle"
|
|
hx-target="#targets-container"
|
|
class="px-2.5 py-1 text-[11px] rounded bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700">
|
|
Toggle
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{{ else }}
|
|
<tr>
|
|
<td colspan="6" class="px-4 py-8 text-center text-slate-500">No search targets configured yet.</td>
|
|
</tr>
|
|
{{ end }}
|
|
</tbody>
|
|
</table>
|
|
</div>{{ end }}`
|
|
|
|
const jobListHTML = `{{ define "jobs" }}
|
|
<div class="overflow-x-auto rounded-xl border border-slate-800">
|
|
<table class="w-full text-left text-xs text-slate-300">
|
|
<thead class="bg-slate-900/80 text-slate-400 uppercase font-semibold text-[10px]">
|
|
<tr>
|
|
<th class="px-4 py-3">Job Title & Company</th>
|
|
<th class="px-4 py-3 text-center">Original Link</th>
|
|
<th class="px-4 py-3">Analysis Status</th>
|
|
<th class="px-4 py-3">Score & Qualification</th>
|
|
<th class="px-4 py-3">Keyword Tags</th>
|
|
<th class="px-4 py-3">Crawled Date</th>
|
|
<th class="px-4 py-3 text-right">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
|
|
{{ range . }}
|
|
<tr class="hover:bg-slate-800/40 transition-colors group">
|
|
<!-- Job Title & Company -->
|
|
<td class="px-4 py-3 font-medium">
|
|
<button hx-get="/jobs/{{ .ID }}"
|
|
hx-target="#detail-modal-body"
|
|
onclick="document.getElementById('detail-modal').classList.remove('hidden')"
|
|
class="text-left font-bold text-white group-hover:text-indigo-300 hover:underline transition-colors block">
|
|
{{ .Title }}
|
|
</button>
|
|
<div class="flex items-center space-x-2 mt-0.5 text-[11px] text-slate-400">
|
|
<span class="font-semibold text-indigo-400">{{ .Company }}</span>
|
|
<span>•</span>
|
|
<span>📍 {{ .Location }}</span>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Original Link -->
|
|
<td class="px-4 py-3 text-center">
|
|
<a href="{{ .ApplyURL }}" target="_blank" title="Open External Job Posting"
|
|
class="inline-flex items-center justify-center w-7 h-7 rounded bg-slate-800 hover:bg-slate-700 text-indigo-400 hover:text-indigo-300 border border-slate-700 transition-colors">
|
|
🔗
|
|
</a>
|
|
</td>
|
|
|
|
<!-- Analysis Status (JobAnalysis.Status) -->
|
|
<td class="px-4 py-3">
|
|
{{ if .Analysis }}
|
|
{{ if eq .Analysis.Status "PARSED" }}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
|
|
PARSED
|
|
</span>
|
|
{{ else if eq .Analysis.Status "FILTERED_OUT" }}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30">
|
|
FILTERED_OUT
|
|
</span>
|
|
{{ else }}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">
|
|
{{ .Analysis.Status }}
|
|
</span>
|
|
{{ end }}
|
|
{{ else }}
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-slate-800 text-slate-400 border border-slate-700">
|
|
UNANALYZED
|
|
</span>
|
|
{{ end }}
|
|
</td>
|
|
|
|
<!-- Score & Qualification -->
|
|
<td class="px-4 py-3">
|
|
{{ if .Analysis }}
|
|
{{ if .Analysis.IsQualified }}
|
|
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .Analysis.Score }}">
|
|
★ {{ printf "%.0f" .Analysis.Score }} Qualified
|
|
</span>
|
|
{{ else }}
|
|
<div class="flex flex-col gap-1 items-start">
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
|
Not Qualified
|
|
</span>
|
|
{{ $missing := parseJSON .Analysis.MissingHighlights }}
|
|
{{ if $missing }}
|
|
<span class="text-[9px] text-rose-400/80 line-clamp-2 max-w-[150px] leading-tight" title="{{ index $missing 0 }}">{{ index $missing 0 }}</span>
|
|
{{ end }}
|
|
</div>
|
|
{{ end }}
|
|
{{ else }}
|
|
<span class="text-slate-500 text-[11px]">-</span>
|
|
{{ end }}
|
|
</td>
|
|
|
|
<!-- Keyword Tags -->
|
|
<td class="px-4 py-3 max-w-xs">
|
|
{{ if .Analysis }}
|
|
<div class="flex flex-wrap gap-1">
|
|
{{ range parseJSON .Analysis.MatchedHighlights }}
|
|
<span class="px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 text-[10px] font-medium border border-emerald-500/20">
|
|
{{ . }}
|
|
</span>
|
|
{{ else }}
|
|
<span class="text-slate-500 text-[10px] italic">No tags</span>
|
|
{{ end }}
|
|
</div>
|
|
{{ else }}
|
|
<span class="text-slate-500 text-[10px] italic">No tags</span>
|
|
{{ end }}
|
|
</td>
|
|
|
|
<!-- Crawled Date -->
|
|
<td class="px-4 py-3 text-slate-400 text-[11px]">
|
|
{{ formatDate .CrawledAt }}
|
|
</td>
|
|
|
|
<!-- Action Button -->
|
|
<td class="px-4 py-3 text-right">
|
|
{{ if .App }}
|
|
<span class="px-2 py-1 rounded bg-violet-500/20 text-violet-300 text-[10px] font-bold border border-violet-500/30">
|
|
{{ .App.Status }}
|
|
</span>
|
|
{{ else }}
|
|
<button hx-post="/applications"
|
|
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
|
|
hx-target="#jobs-container"
|
|
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-[11px] font-medium transition-colors">
|
|
+ Save
|
|
</button>
|
|
{{ end }}
|
|
</td>
|
|
</tr>
|
|
{{ else }}
|
|
<tr>
|
|
<td colspan="7" class="px-4 py-8 text-center text-slate-500">
|
|
No job postings found yet. Click <strong>🔍 Discover Jobs</strong> to enqueue crawler tasks.
|
|
</td>
|
|
</tr>
|
|
{{ end }}
|
|
</tbody>
|
|
</table>
|
|
</div>{{ end }}`
|
|
|
|
const jobDetailHTML = `{{ define "jobDetail" }}
|
|
<div class="space-y-6">
|
|
<div class="flex items-start justify-between border-b border-slate-800 pb-4">
|
|
<div>
|
|
<span class="text-xs font-bold text-indigo-400 uppercase tracking-wide">{{ .Company }}</span>
|
|
<h2 class="text-xl font-extrabold text-white mt-1">{{ .Title }}</h2>
|
|
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} • Ingested {{ formatDate .CrawledAt }}</p>
|
|
</div>
|
|
<button onclick="closeDetailModal()" class="text-slate-400 hover:text-white text-lg p-1">✕</button>
|
|
</div>
|
|
|
|
{{ if .Analysis }}
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
|
|
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Match Relevance Score</span>
|
|
<span class="text-2xl font-black text-emerald-400 mt-1 block">{{ printf "%.1f" .Analysis.Score }} / 100</span>
|
|
</div>
|
|
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
|
|
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">LLM Status</span>
|
|
<span class="text-sm font-bold text-indigo-300 mt-1 block uppercase">{{ .Analysis.Status }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Matched & Missing Highlights -->
|
|
<div class="space-y-4">
|
|
<div>
|
|
<h4 class="text-xs font-bold text-emerald-400 uppercase tracking-wider mb-2">✓ Matched Technical Highlights</h4>
|
|
<div class="flex flex-wrap gap-2">
|
|
{{ range parseJSON .Analysis.MatchedHighlights }}
|
|
<span class="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-300 text-xs font-semibold border border-emerald-500/30">
|
|
{{ . }}
|
|
</span>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 class="text-xs font-bold text-rose-400 uppercase tracking-wider mb-2">! Skill & Experience Gaps</h4>
|
|
<div class="flex flex-wrap gap-2">
|
|
{{ range parseJSON .Analysis.MissingHighlights }}
|
|
<span class="px-2.5 py-1 rounded-lg bg-rose-500/15 text-rose-300 text-xs font-semibold border border-rose-500/30">
|
|
{{ . }}
|
|
</span>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 class="text-xs font-bold text-amber-400 uppercase tracking-wider mb-2">💡 Resume Tailoring Action Points</h4>
|
|
<ul class="space-y-2">
|
|
{{ range parseJSON .Analysis.TailoringSuggestions }}
|
|
<li class="p-3 rounded-lg bg-slate-800/80 border border-slate-700/60 text-xs text-slate-200 leading-relaxed">
|
|
• {{ . }}
|
|
</li>
|
|
{{ end }}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
{{ end }}
|
|
|
|
<!-- Raw Content snippet -->
|
|
<div class="border-t border-slate-800 pt-4">
|
|
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Job Description Payload</h4>
|
|
<div class="p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-300 max-h-60 overflow-y-auto leading-relaxed custom-scrollbar whitespace-pre-wrap">
|
|
{{ .RawContent }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions Footer -->
|
|
<div class="flex items-center justify-between border-t border-slate-800 pt-4">
|
|
<a href="{{ .ApplyURL }}" target="_blank" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-xs font-bold text-white rounded-lg shadow-md transition-all">
|
|
Apply External ↗
|
|
</a>
|
|
|
|
<div class="flex space-x-2">
|
|
<button hx-post="/jobs/{{ .ID }}/analyze"
|
|
hx-target="#detail-modal-body"
|
|
class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-200 rounded-lg border border-slate-700">
|
|
🔄 Re-run LLM
|
|
</button>
|
|
<button hx-post="/applications"
|
|
hx-vals='{"raw_job_id": {{ .ID }}, "status": "APPLIED"}'
|
|
hx-target="#kanban-container"
|
|
onclick="switchTab('kanban'); closeDetailModal();"
|
|
class="px-3 py-2 bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white rounded-lg shadow-md">
|
|
+ Mark Applied
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>{{ end }}`
|
|
|
|
const kanbanBoardHTML = `{{ define "kanban" }}
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<!-- SAVED COLUMN -->
|
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
|
|
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
|
|
<h3 class="text-xs font-bold text-slate-300 uppercase">💾 Saved</h3>
|
|
</div>
|
|
<div class="space-y-3 flex-1">
|
|
{{ range . }}
|
|
{{ if eq .App.Status "SAVED" }}
|
|
{{ template "kanbanCard" . }}
|
|
{{ end }}
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- APPLIED COLUMN -->
|
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
|
|
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
|
|
<h3 class="text-xs font-bold text-indigo-400 uppercase">🚀 Applied</h3>
|
|
</div>
|
|
<div class="space-y-3 flex-1">
|
|
{{ range . }}
|
|
{{ if eq .App.Status "APPLIED" }}
|
|
{{ template "kanbanCard" . }}
|
|
{{ end }}
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- INTERVIEW COLUMN -->
|
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
|
|
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
|
|
<h3 class="text-xs font-bold text-amber-400 uppercase">💬 Interview</h3>
|
|
</div>
|
|
<div class="space-y-3 flex-1">
|
|
{{ range . }}
|
|
{{ if eq .App.Status "INTERVIEW" }}
|
|
{{ template "kanbanCard" . }}
|
|
{{ end }}
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- OFFER / CLOSED COLUMN -->
|
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
|
|
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
|
|
<h3 class="text-xs font-bold text-emerald-400 uppercase">🎉 Offer / Closed</h3>
|
|
</div>
|
|
<div class="space-y-3 flex-1">
|
|
{{ range . }}
|
|
{{ if or (eq .App.Status "OFFER") (eq .App.Status "REJECTED") }}
|
|
{{ template "kanbanCard" . }}
|
|
{{ end }}
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
</div>{{ end }}`
|
|
|
|
const kanbanCardHTML = `
|
|
{{ define "kanbanCard" }}
|
|
<div class="p-4 rounded-xl bg-slate-800/80 border border-slate-700/60 shadow-md space-y-3">
|
|
<div>
|
|
<span class="text-[10px] font-bold text-indigo-300 uppercase">{{ .Company }}</span>
|
|
<h4 class="text-xs font-bold text-white mt-0.5">{{ .Title }}</h4>
|
|
<span class="text-[10px] text-slate-400 mt-1 block">📍 {{ .Location }}</span>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between pt-2 border-t border-slate-700/50 text-[10px]">
|
|
<span class="text-slate-400">Status: <strong class="text-white">{{ .App.Status }}</strong></span>
|
|
|
|
<select hx-patch="/applications/{{ .App.ID }}/status"
|
|
hx-target="#kanban-container"
|
|
class="px-1.5 py-0.5 bg-slate-900 border border-slate-700 rounded text-slate-200 text-[10px]">
|
|
<option value="SAVED" {{ if eq .App.Status "SAVED" }}selected{{ end }}>Saved</option>
|
|
<option value="APPLIED" {{ if eq .App.Status "APPLIED" }}selected{{ end }}>Applied</option>
|
|
<option value="INTERVIEW" {{ if eq .App.Status "INTERVIEW" }}selected{{ end }}>Interview</option>
|
|
<option value="OFFER" {{ if eq .App.Status "OFFER" }}selected{{ end }}>Offer</option>
|
|
<option value="REJECTED" {{ if eq .App.Status "REJECTED" }}selected{{ end }}>Rejected</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{{ end }}`
|