feat: implement task queue statistics and add UI templates for job management and visualization.
This commit is contained in:
@@ -2,8 +2,4 @@ targets:
|
|||||||
- name: "Google Software Engineer (Seattle, MID Level)"
|
- name: "Google Software Engineer (Seattle, MID Level)"
|
||||||
target_url: "https://www.google.com/about/careers/applications/jobs/results/?employment_type=FULL_TIME&q=%22Software%20Engineer%22&location=Seattle%2C%20WA%2C%20USA&target_level=MID"
|
target_url: "https://www.google.com/about/careers/applications/jobs/results/?employment_type=FULL_TIME&q=%22Software%20Engineer%22&location=Seattle%2C%20WA%2C%20USA&target_level=MID"
|
||||||
platform: "google_jobs"
|
platform: "google_jobs"
|
||||||
is_active: true
|
is_active: true
|
||||||
- name: "DeepMind"
|
|
||||||
target_url: "https://www.google.com/about/careers/applications/jobs/results?company=DeepMind"
|
|
||||||
platform: "google_jobs"
|
|
||||||
is_active: true
|
|
||||||
@@ -3,15 +3,17 @@ module github.com/hireme/hireme
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
|
||||||
github.com/mattn/go-sqlite3 v1.14.42
|
github.com/mattn/go-sqlite3 v1.14.42
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/pflag v1.0.6
|
github.com/spf13/pflag v1.0.6
|
||||||
golang.org/x/net v0.52.0
|
go.uber.org/zap v1.28.0
|
||||||
|
golang.org/x/net v0.55.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/JohannesKaufmann/dom v0.3.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
go.uber.org/zap v1.28.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
|
||||||
|
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
||||||
|
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
||||||
|
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
@@ -14,6 +18,8 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
|||||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// QueueStats holds task queue counters
|
||||||
|
type QueueStats struct {
|
||||||
|
Pending int `json:"pending"`
|
||||||
|
Running int `json:"running"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQueueStats returns counts for each task status
|
||||||
|
func (db *DB) GetQueueStats() (*QueueStats, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'RUNNING' THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END), 0)
|
||||||
|
FROM tasks
|
||||||
|
`
|
||||||
|
var stats QueueStats
|
||||||
|
err := db.QueryRow(query).Scan(&stats.Pending, &stats.Running, &stats.Failed, &stats.Completed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// EnqueueTask adds a new task to the queue
|
// EnqueueTask adds a new task to the queue
|
||||||
func (db *DB) EnqueueTask(taskType string, payload interface{}) error {
|
func (db *DB) EnqueueTask(taskType string, payload interface{}) error {
|
||||||
payloadBytes, err := json.Marshal(payload)
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
|||||||
+63
-10
@@ -1,9 +1,9 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/hireme/hireme/internal/config"
|
"github.com/hireme/hireme/internal/config"
|
||||||
"github.com/hireme/hireme/internal/db"
|
"github.com/hireme/hireme/internal/db"
|
||||||
@@ -33,12 +33,15 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /jobs", s.handleGetJobs)
|
mux.HandleFunc("GET /jobs", s.handleGetJobs)
|
||||||
mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail)
|
mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail)
|
||||||
mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob)
|
mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob)
|
||||||
|
mux.HandleFunc("POST /jobs/{id}/opt-out", s.handleOptOut)
|
||||||
|
mux.HandleFunc("GET /applications", s.handleGetApplications)
|
||||||
mux.HandleFunc("POST /applications", s.handleCreateApplication)
|
mux.HandleFunc("POST /applications", s.handleCreateApplication)
|
||||||
mux.HandleFunc("PATCH /applications/{id}/status", s.handleUpdateAppStatus)
|
mux.HandleFunc("PATCH /applications/{id}/status", s.handleUpdateAppStatus)
|
||||||
mux.HandleFunc("POST /api/tasks/discover", s.handleDiscoverTasks)
|
mux.HandleFunc("POST /api/tasks/discover", s.handleDiscoverTasks)
|
||||||
mux.HandleFunc("POST /api/tasks/clean", s.handleCleanTasks)
|
mux.HandleFunc("POST /api/tasks/clean", s.handleCleanTasks)
|
||||||
mux.HandleFunc("POST /api/tasks/reprocess", s.handleReprocessJobs)
|
mux.HandleFunc("POST /api/tasks/reprocess", s.handleReprocessJobs)
|
||||||
mux.HandleFunc("POST /api/tasks/toggle", s.handleToggleTasks)
|
mux.HandleFunc("POST /api/tasks/toggle", s.handleToggleTasks)
|
||||||
|
mux.HandleFunc("GET /api/status", s.handleGetStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -189,6 +192,39 @@ func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write([]byte(`<span class="text-xs px-2 py-1 bg-yellow-100 text-yellow-800 rounded">Analysis Queued</span>`))
|
_, _ = w.Write([]byte(`<span class="text-xs px-2 py-1 bg-yellow-100 text-yellow-800 rounded">Analysis Queued</span>`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleOptOut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid job id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.CreateOrUpdateApplication(id, "OPTED_OUT", "")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, err := s.db.GetJobsWithAnalysis()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = RenderJobList(w, jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetApplications(w http.ResponseWriter, r *http.Request) {
|
||||||
|
apps, err := s.db.GetApplications()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = RenderApplyTable(w, apps)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -213,13 +249,7 @@ func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(r.Header.Get("HX-Target"), "kanban") {
|
// Always return the updated job list so the action column refreshes
|
||||||
apps, _ := s.db.GetApplications()
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
_ = RenderKanbanBoard(w, apps)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.handleGetJobs(w, r)
|
s.handleGetJobs(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +300,7 @@ func (s *Server) handleUpdateAppStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
updatedApps, _ := s.db.GetApplications()
|
updatedApps, _ := s.db.GetApplications()
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = RenderKanbanBoard(w, updatedApps)
|
_ = RenderApplyTable(w, updatedApps)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -295,7 +325,7 @@ func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status": "success", "message": "Queued ` + strconv.Itoa(count) + ` discovery tasks"}`))
|
_, _ = w.Write([]byte(`{"status": "success", "message": "Queued ` + strconv.Itoa(count) + ` discovery tasks"}`))
|
||||||
}
|
}
|
||||||
@@ -332,3 +362,26 @@ func (s *Server) handleToggleTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status": "` + status + `"}`))
|
_, _ = w.Write([]byte(`{"status": "` + status + `"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetStatus returns a JSON snapshot of worker pool state and task queue counts.
|
||||||
|
// Used by the bottom bar to auto-refresh every few seconds without a full page reload.
|
||||||
|
func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
workerStatus := "paused"
|
||||||
|
if s.pool.IsRunning() {
|
||||||
|
workerStatus = "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := s.db.GetQueueStats()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"worker_status": workerStatus,
|
||||||
|
"queue": stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
const applyTabHTML = `{{ define "applyTab" }}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-bold text-white">Application Pipeline</h2>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">Track all saved and in-progress applications</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<input id="apply-filter-text" type="search" placeholder="Search title, company…"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500 w-44 placeholder:text-slate-500">
|
||||||
|
<select id="apply-filter-status"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
|
||||||
|
<option value="active">Active (Saved + In Progress)</option>
|
||||||
|
<option value="SAVED">💾 Saved</option>
|
||||||
|
<option value="APPLIED">🚀 Applied</option>
|
||||||
|
<option value="INTERVIEW">💬 Interview</option>
|
||||||
|
<option value="OFFER">🎉 Offer</option>
|
||||||
|
<option value="REJECTED">❌ Rejected</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
<button hx-get="/applications" hx-target="#apply-container" hx-swap="innerHTML"
|
||||||
|
class="px-3 py-1.5 text-xs font-semibold text-indigo-400 hover:text-indigo-300 bg-slate-800 border border-slate-700 rounded-lg transition-colors">
|
||||||
|
↺ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Apply Table Container -->
|
||||||
|
<div id="apply-container"
|
||||||
|
hx-get="/applications" hx-trigger="refresh" hx-swap="innerHTML">
|
||||||
|
{{ template "applyTable" .Applications }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}`
|
||||||
|
|
||||||
|
const applyTableHTML = `{{ define "applyTable" }}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-slate-800">
|
||||||
|
<table id="apply-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" data-sort="title">Job & Company</th>
|
||||||
|
<th class="px-4 py-3">Pipeline Stage</th>
|
||||||
|
<th class="px-4 py-3" data-sort="score">Score</th>
|
||||||
|
<th class="px-4 py-3 text-center">Link</th>
|
||||||
|
<th class="px-4 py-3" data-sort="updated">Last Updated</th>
|
||||||
|
<th class="px-4 py-3 text-right">Change Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
|
||||||
|
{{ range . }}
|
||||||
|
{{ if .App }}
|
||||||
|
<tr class="hover:bg-slate-800/40 transition-colors group" data-row data-app-status="{{ .App.Status }}">
|
||||||
|
|
||||||
|
<!-- Title & Company -->
|
||||||
|
<td class="px-4 py-3" data-sort-val="{{ .Title }}">
|
||||||
|
<button hx-get="/jobs/{{ .ID }}"
|
||||||
|
hx-target="#detail-modal-body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="openDetailModal()"
|
||||||
|
class="text-left font-bold text-white group-hover:text-indigo-300 hover:underline block truncate max-w-[200px]">
|
||||||
|
{{ .Title }}
|
||||||
|
</button>
|
||||||
|
<span class="text-[11px] text-indigo-400 font-semibold">{{ .Company }}</span>
|
||||||
|
<span class="text-[11px] text-slate-500 ml-1">📍 {{ .Location }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Pipeline Stage (arrow indicator) -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-0.5 text-[10px] font-semibold">
|
||||||
|
<span class="px-2 py-0.5 rounded-l-full {{ if eq .App.Status "SAVED" }}bg-slate-500 text-white{{ else }}bg-slate-800/60 text-slate-600{{ end }}">Saved</span>
|
||||||
|
<span class="text-slate-700">▶</span>
|
||||||
|
<span class="px-2 py-0.5 {{ if eq .App.Status "APPLIED" }}bg-indigo-600 text-white{{ else }}bg-slate-800/60 text-slate-600{{ end }}">Applied</span>
|
||||||
|
<span class="text-slate-700">▶</span>
|
||||||
|
<span class="px-2 py-0.5 {{ if eq .App.Status "INTERVIEW" }}bg-amber-500 text-white{{ else }}bg-slate-800/60 text-slate-600{{ end }}">Interview</span>
|
||||||
|
<span class="text-slate-700">▶</span>
|
||||||
|
{{ if eq .App.Status "OFFER" }}
|
||||||
|
<span class="px-2 py-0.5 rounded-r-full bg-emerald-500 text-white">Offer 🎉</span>
|
||||||
|
{{ else if eq .App.Status "REJECTED" }}
|
||||||
|
<span class="px-2 py-0.5 rounded-r-full bg-rose-500 text-white">Rejected</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="px-2 py-0.5 rounded-r-full bg-slate-800/60 text-slate-600">Offer</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Score -->
|
||||||
|
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
|
||||||
|
{{ if .Analysis }}
|
||||||
|
<span class="font-bold {{ if .Analysis.IsQualified }}text-emerald-400{{ else }}text-slate-500{{ end }}">
|
||||||
|
{{ printf "%.0f" .Analysis.Score }}
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-slate-600">–</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Link -->
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<a href="{{ .ApplyURL }}" target="_blank"
|
||||||
|
class="inline-flex items-center justify-center w-7 h-7 rounded bg-slate-800 hover:bg-slate-700 text-indigo-400 border border-slate-700 transition-colors">
|
||||||
|
🔗
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Last Updated -->
|
||||||
|
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .App.UpdatedAt.Unix }}">
|
||||||
|
{{ formatDate .App.UpdatedAt }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Change Status -->
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<select hx-patch="/applications/{{ .App.ID }}/status"
|
||||||
|
hx-target="#apply-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="px-2 py-1 bg-slate-900 border border-slate-700 rounded-lg text-slate-200 text-[10px] focus:outline-none focus:border-indigo-500">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-4 py-12 text-center text-slate-500">
|
||||||
|
<p class="text-lg mb-2">📋</p>
|
||||||
|
<p>No saved applications yet. Go to <strong>Jobs</strong> tab and click <strong>+ Save</strong> on any job.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ end }}`
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
const jobsTabHTML = `{{ define "jobsTab" }}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-bold text-white">Job Opportunities</h2>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">All ingested postings · AI-scored · sortable</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<input id="job-filter-text" type="search" placeholder="Search title, company…"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500 w-48 placeholder:text-slate-500">
|
||||||
|
<!-- Filter order: most frequent → least frequent use -->
|
||||||
|
<select id="job-filter-qual"
|
||||||
|
class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
|
||||||
|
<option value="default">✅ Qualified (Default)</option>
|
||||||
|
<option value="not-qualified">❌ Not Qualified</option>
|
||||||
|
<option value="saved">💾 Saved / In Pipeline</option>
|
||||||
|
<option value="all">🌐 All Jobs</option>
|
||||||
|
<option value="opted-out">🚫 Opted-out</option>
|
||||||
|
</select>
|
||||||
|
<button hx-get="/jobs" hx-target="#jobs-container" hx-swap="innerHTML"
|
||||||
|
class="px-3 py-1.5 text-xs font-semibold text-indigo-400 hover:text-indigo-300 bg-slate-800 border border-slate-700 rounded-lg transition-colors">
|
||||||
|
↺ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job List Container (HTMX auto-refreshed every 12s) -->
|
||||||
|
<div id="jobs-container"
|
||||||
|
hx-get="/jobs" hx-trigger="refresh" hx-swap="innerHTML">
|
||||||
|
{{ template "jobs" .Jobs }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}`
|
||||||
|
|
||||||
|
const jobListHTML = `{{ define "jobs" }}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-slate-800">
|
||||||
|
<table id="jobs-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" data-sort="title">Job Title & Company</th>
|
||||||
|
<th class="px-4 py-3 text-center">Link</th>
|
||||||
|
<th class="px-4 py-3" data-sort="status">Analysis</th>
|
||||||
|
<th class="px-4 py-3" data-sort="score">Score & Qualification</th>
|
||||||
|
<th class="px-4 py-3">Reason / Tags</th>
|
||||||
|
<th class="px-4 py-3" data-sort="date">Crawled</th>
|
||||||
|
<th class="px-4 py-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
|
||||||
|
{{ range . }}
|
||||||
|
{{- $qual := "pending" -}}
|
||||||
|
{{- if .Analysis }}{{ if .Analysis.IsQualified }}{{ $qual = "qualified" }}{{ else }}{{ $qual = "not-qualified" }}{{ end }}{{ end -}}
|
||||||
|
{{- $appClass := "none" -}}
|
||||||
|
{{- if .App }}{{ if eq .App.Status "OPTED_OUT" }}{{ $appClass = "opted-out" }}{{ else }}{{ $appClass = "pipeline" }}{{ end }}{{ end -}}
|
||||||
|
<tr class="hover:bg-slate-800/40 transition-colors group" data-row data-qual="{{ $qual }}" data-app="{{ $appClass }}">
|
||||||
|
|
||||||
|
<!-- Title & Company -->
|
||||||
|
<td class="px-4 py-3 font-medium" data-sort-val="{{ .Title }}">
|
||||||
|
<button hx-get="/jobs/{{ .ID }}"
|
||||||
|
hx-target="#detail-modal-body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="openDetailModal()"
|
||||||
|
class="text-left font-bold text-white group-hover:text-indigo-300 hover:underline transition-colors block truncate max-w-xs">
|
||||||
|
{{ .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>
|
||||||
|
|
||||||
|
<!-- External Link -->
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<a href="{{ .ApplyURL }}" target="_blank" title="Open original 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 -->
|
||||||
|
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ .Analysis.Status }}{{ else }}UNANALYZED{{ end }}">
|
||||||
|
{{ 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 "PENDING" }}
|
||||||
|
<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">PENDING</span>
|
||||||
|
{{ else if eq .Analysis.Status "DONE" }}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">DONE</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-500/20 text-slate-400 border border-slate-600/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-500 border border-slate-700">UNANALYZED</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Score & Qualification -->
|
||||||
|
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
|
||||||
|
{{ 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 }}
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||||
|
✗ Not Qualified
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-slate-500 text-[11px]">–</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Reason / Matched Tags -->
|
||||||
|
<td class="px-4 py-3 max-w-[180px]">
|
||||||
|
{{ if .App }}{{ if eq .App.Status "OPTED_OUT" }}
|
||||||
|
<span class="text-[10px] text-slate-500 italic">Opted out</span>
|
||||||
|
{{ end }}{{ end }}
|
||||||
|
{{ if .Analysis }}
|
||||||
|
{{ if .Analysis.IsQualified }}
|
||||||
|
<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 }}
|
||||||
|
{{ $missing := parseJSON .Analysis.MissingHighlights }}
|
||||||
|
{{ if $missing }}
|
||||||
|
<span class="text-[10px] text-rose-400/90 leading-snug line-clamp-2" title="{{ index $missing 0 }}">{{ index $missing 0 }}</span>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .CrawledAt.Unix }}">
|
||||||
|
{{ formatDate .CrawledAt }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions: Save + Opt-out OR status badge -->
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
{{ if .App }}
|
||||||
|
{{ if eq .App.Status "OPTED_OUT" }}
|
||||||
|
<!-- Opted out: show undo button -->
|
||||||
|
<button hx-post="/applications"
|
||||||
|
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
|
||||||
|
hx-target="#jobs-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
title="Undo opt-out"
|
||||||
|
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 border border-slate-700 text-[10px] transition-colors">
|
||||||
|
↩ Undo
|
||||||
|
</button>
|
||||||
|
{{ else }}
|
||||||
|
<!-- In pipeline -->
|
||||||
|
<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>
|
||||||
|
{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
<!-- No app: show Save + Opt-out -->
|
||||||
|
<div class="flex items-center justify-end gap-1.5">
|
||||||
|
<button hx-post="/applications"
|
||||||
|
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
|
||||||
|
hx-target="#jobs-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="px-2.5 py-1 rounded bg-indigo-700 hover:bg-indigo-600 text-indigo-100 border border-indigo-600 text-[10px] font-semibold transition-colors">
|
||||||
|
+ Save
|
||||||
|
</button>
|
||||||
|
<button hx-post="/jobs/{{ .ID }}/opt-out"
|
||||||
|
hx-target="#jobs-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
title="Hide this job"
|
||||||
|
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-rose-900/40 text-slate-500 hover:text-rose-400 border border-slate-700 text-[10px] transition-colors">
|
||||||
|
🚫
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-4 py-12 text-center text-slate-500">
|
||||||
|
<p class="text-lg mb-2">📭</p>
|
||||||
|
<p>No job postings found. Click <strong>🔍 Discover</strong> in the bottom bar to start crawling.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ end }}`
|
||||||
|
|
||||||
|
const jobDetailHTML = `{{ define "jobDetail" }}
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between border-b border-slate-800 pb-4">
|
||||||
|
<div class="flex-1 min-w-0 pr-4">
|
||||||
|
<span class="text-xs font-bold text-indigo-400 uppercase tracking-wide">{{ .Company }}</span>
|
||||||
|
<h2 class="text-xl font-extrabold text-white mt-0.5 leading-tight">{{ .Title }}</h2>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} • Ingested {{ formatDate .CrawledAt }}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeDetailModal()" class="flex-shrink-0 text-slate-400 hover:text-white text-lg p-1 rounded hover:bg-slate-800 transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .Analysis }}
|
||||||
|
<!-- Score + Status Grid -->
|
||||||
|
<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 Score</span>
|
||||||
|
{{ if .Analysis.IsQualified }}
|
||||||
|
<span class="text-2xl font-black text-emerald-400 mt-1 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-sm font-medium text-slate-400">/ 100</span></span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-base font-bold text-rose-400 mt-1 block">Not Qualified</span>
|
||||||
|
{{ end }}
|
||||||
|
</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">Analysis Status</span>
|
||||||
|
<span class="text-sm font-bold text-indigo-300 mt-1 block uppercase">{{ .Analysis.Status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Highlights -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
{{ if .Analysis.IsQualified }}
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-emerald-400 uppercase tracking-wider mb-2">✓ Matched 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>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-slate-500 text-xs italic">None identified</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-rose-400 uppercase tracking-wider mb-2">! {{ if .Analysis.IsQualified }}Gaps / Missing Skills{{ else }}Disqualification Reasons{{ end }}</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>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-slate-500 text-xs italic">None identified</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .Analysis.IsQualified }}
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-amber-400 uppercase tracking-wider mb-2">💡 Resume Tailoring Tips</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>
|
||||||
|
{{ else }}
|
||||||
|
<li class="text-slate-500 text-xs italic">None</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<!-- Job Description (stored as Markdown) -->
|
||||||
|
<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</h4>
|
||||||
|
<div class="p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-300 max-h-72 overflow-y-auto leading-relaxed custom-scrollbar whitespace-pre-wrap font-mono">
|
||||||
|
{{ rawContent .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"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
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="#jobs-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="switchTab('apply'); 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 }}`
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
|
||||||
|
const kanbanBoardHTML = `{{ define "kanban" }}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- SAVED -->
|
||||||
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[400px]">
|
||||||
|
<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>
|
||||||
|
<span class="text-[10px] text-slate-500">{{ range . }}{{ if eq .App.Status "SAVED" }}+1{{ end }}{{ end }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 flex-1">
|
||||||
|
{{ range . }}
|
||||||
|
{{ if eq .App.Status "SAVED" }}{{ template "kanbanCard" . }}{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- APPLIED -->
|
||||||
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[400px]">
|
||||||
|
<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 -->
|
||||||
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[400px]">
|
||||||
|
<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 / REJECTED -->
|
||||||
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[400px]">
|
||||||
|
<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 leading-snug">{{ .Title }}</h4>
|
||||||
|
<span class="text-[10px] text-slate-400 mt-1 block">📍 {{ .Location }}</span>
|
||||||
|
</div>
|
||||||
|
{{ if .Analysis }}
|
||||||
|
<div class="text-[10px] text-slate-400">
|
||||||
|
Score: <strong class="{{ scoreBadgeClass .Analysis.Score }}">{{ printf "%.0f" .Analysis.Score }}</strong>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<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"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
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 }}`
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
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">Qualified:</span>
|
||||||
|
<span class="font-bold text-emerald-400">{{ .QualifiedJobsCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1.5">
|
||||||
|
<span class="text-slate-400">Ingested:</span>
|
||||||
|
<span class="font-bold text-white">{{ .TotalIngestedCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1.5">
|
||||||
|
<span class="text-slate-400">Applied:</span>
|
||||||
|
<span class="font-bold text-violet-400">{{ .ActiveApplications }}</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>
|
||||||
|
|
||||||
|
<!-- 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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' && (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();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>{{ end }}`
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
|
||||||
|
const targetsTabHTML = `{{ define "targetsTab" }}
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-bold text-white">Crawler Targets</h2>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">Configure job board URLs to crawl for new postings</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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 New Target</h3>
|
||||||
|
<form hx-post="/targets" hx-target="#targets-container" hx-swap="innerHTML"
|
||||||
|
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 placeholder:text-slate-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 placeholder:text-slate-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="google_jobs">🔍 Google Jobs</option>
|
||||||
|
<!-- More platforms will be added as crawlers are implemented -->
|
||||||
|
</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>
|
||||||
|
{{ 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"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="px-2.5 py-1 text-[11px] rounded bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-colors">
|
||||||
|
{{ if .IsActive }}Pause{{ else }}Activate{{ end }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-4 py-10 text-center text-slate-500">
|
||||||
|
<p class="text-lg mb-2">🎯</p>
|
||||||
|
<p>No search targets configured yet. Add one above.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ end }}`
|
||||||
+38
-619
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,8 +50,20 @@ var funcMap = template.FuncMap{
|
|||||||
}
|
}
|
||||||
return "🌐 Web Target"
|
return "🌐 Web Target"
|
||||||
},
|
},
|
||||||
|
// rawContent returns the stored Markdown string (already clean)
|
||||||
|
"rawContent": func(s string) string {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
},
|
||||||
|
// htmlStripTags is a safety net for any legacy HTML still in the DB
|
||||||
|
"htmlStripTags": func(s string) string {
|
||||||
|
re := regexp.MustCompile(`<[^>]+>`)
|
||||||
|
clean := re.ReplaceAllString(s, " ")
|
||||||
|
space := regexp.MustCompile(`\s+`)
|
||||||
|
return strings.TrimSpace(space.ReplaceAllString(clean, " "))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PageData is the data payload for the main page render.
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Targets []db.SearchTarget
|
Targets []db.SearchTarget
|
||||||
Jobs []db.JobWithAnalysis
|
Jobs []db.JobWithAnalysis
|
||||||
@@ -62,36 +75,32 @@ type PageData struct {
|
|||||||
ActiveApplications int
|
ActiveApplications int
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var rootTemplate *template.Template
|
||||||
rootTemplate *template.Template
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
// All template HTML strings are collected here in a defined order.
|
||||||
|
// Sub-files do NOT register via init() — they export named constants that
|
||||||
|
// are referenced directly in this slice to avoid init ordering issues.
|
||||||
|
sources := []string{
|
||||||
|
mainLayoutHTML,
|
||||||
|
jobsTabHTML,
|
||||||
|
jobListHTML,
|
||||||
|
jobDetailHTML,
|
||||||
|
targetsTabHTML,
|
||||||
|
targetListHTML,
|
||||||
|
applyTabHTML,
|
||||||
|
applyTableHTML,
|
||||||
|
kanbanBoardHTML,
|
||||||
|
kanbanCardHTML,
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
rootTemplate = template.New("root").Funcs(funcMap)
|
rootTemplate = template.New("root").Funcs(funcMap)
|
||||||
rootTemplate, err = rootTemplate.Parse(mainLayoutHTML)
|
for _, src := range sources {
|
||||||
if err != nil {
|
rootTemplate, err = rootTemplate.Parse(src)
|
||||||
panic(err)
|
if err != nil {
|
||||||
}
|
panic("template parse error: " + err.Error())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,596 +124,6 @@ func RenderKanbanBoard(w io.Writer, apps []db.JobWithAnalysis) error {
|
|||||||
return rootTemplate.ExecuteTemplate(w, "kanban", apps)
|
return rootTemplate.ExecuteTemplate(w, "kanban", apps)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
|
func RenderApplyTable(w io.Writer, apps []db.JobWithAnalysis) error {
|
||||||
<html lang="en" class="dark">
|
return rootTemplate.ExecuteTemplate(w, "applyTable", apps)
|
||||||
<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/reprocess"
|
|
||||||
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-amber-600 rounded-lg shadow-md hover:bg-amber-500 active:scale-95 shadow-amber-500/25">
|
|
||||||
<span>🔄 Reprocess All Jobs</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 }}`
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
md "github.com/JohannesKaufmann/html-to-markdown/v2"
|
||||||
"github.com/hireme/hireme/internal/crawler"
|
"github.com/hireme/hireme/internal/crawler"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
@@ -130,17 +131,27 @@ func (g *GoogleCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*c
|
|||||||
location = "See Job Posting"
|
location = "See Job Posting"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw content: render the <main> container HTML
|
// Raw content: extract <main> HTML, convert to Markdown for LLM efficiency
|
||||||
var buf bytes.Buffer
|
var rawMarkdown string
|
||||||
doc, parseErr := html.Parse(strings.NewReader(htmlContent))
|
doc, parseErr := html.Parse(strings.NewReader(htmlContent))
|
||||||
if parseErr == nil {
|
if parseErr == nil {
|
||||||
if main := findElement(doc, "main"); main != nil {
|
if mainEl := findElement(doc, "main"); mainEl != nil {
|
||||||
_ = html.Render(&buf, main)
|
var buf bytes.Buffer
|
||||||
|
_ = html.Render(&buf, mainEl)
|
||||||
|
converted, convErr := md.ConvertString(buf.String())
|
||||||
|
if convErr == nil && len(converted) > 50 {
|
||||||
|
rawMarkdown = converted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if buf.Len() < 100 {
|
if rawMarkdown == "" {
|
||||||
buf.Reset()
|
// Fallback: convert the whole page
|
||||||
buf.WriteString(htmlContent)
|
converted, convErr := md.ConvertString(htmlContent)
|
||||||
|
if convErr == nil {
|
||||||
|
rawMarkdown = converted
|
||||||
|
} else {
|
||||||
|
rawMarkdown = htmlContent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &crawler.ExtractedJob{
|
return &crawler.ExtractedJob{
|
||||||
@@ -149,7 +160,7 @@ func (g *GoogleCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*c
|
|||||||
Company: company,
|
Company: company,
|
||||||
Location: location,
|
Location: location,
|
||||||
ApplyURL: applyURL,
|
ApplyURL: applyURL,
|
||||||
RawContent: buf.String(),
|
RawContent: rawMarkdown,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user