joblin / frontend /dashboard.html
Britzzy's picture
fix: scrapers, cron, design, filters, categories, graduate detection
8a6d881
Raw
History Blame Contribute Delete
17 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Joblin - Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar">
<a href="dashboard.html" class="sidebar-brand">
<div class="brand-icon">
<svg class="brand-logo-svg" width="30" height="30" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="32" height="32" rx="8" fill="url(#brandGrad)" stroke="rgba(255,255,255,0.15)" stroke-width="1.5"/>
<path d="M14 11H22V15H18V21C18 22.6569 16.6569 24 15 24C13.3431 24 12 22.6569 12 21H15C15 21.5523 15.4477 22 16 22C16.5523 22 17 21.5523 17 21V15H14V11Z" fill="#FFFFFF"/>
<defs>
<linearGradient id="brandGrad" x1="0" y1="0" x2="36" y2="36" gradientUnits="userSpaceOnUse">
<stop stop-color="#06b6d4"/>
<stop offset="1" stop-color="#0891b2"/>
</linearGradient>
</defs>
</svg>
</div>
<h1>Job<span>lin</span></h1>
</a>
<nav class="sidebar-nav">
<div class="sidebar-section-title">Main</div>
<a href="dashboard.html" class="nav-item active">
<span class="nav-icon">
<svg class="nav-icon-svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
</span>
<span class="nav-label">Dashboard</span>
</a>
<a href="jobs.html" class="nav-item">
<span class="nav-icon">
<svg class="nav-icon-svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="7" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
</span>
<span class="nav-label">All Jobs</span>
</a>
<a href="manual-job.html" class="nav-item">
<span class="nav-icon">
<svg class="nav-icon-svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="16"/><line x1="8" x2="16" y1="12" y2="12"/></svg>
</span>
<span class="nav-label">Manual Entry</span>
</a>
<div class="sidebar-section-title">My Profile</div>
<a href="make-cv.html" class="nav-item">
<span class="nav-icon">
<svg class="nav-icon-svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>
</span>
<span class="nav-label">Make My CV</span>
</a>
<a href="cv-editor.html" class="nav-item">
<span class="nav-icon">
<svg class="nav-icon-svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 13.5V20a2 2 0 0 0 2 2h8.5"/><path d="M20 12.5V4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v6"/><path d="m14 2-2 2h6"/><path d="M18.42 15.61a2.1 2.1 0 1 1 2.97 2.97L11 22 8 22l0-3L18.42 15.61Z"/></svg>
</span>
<span class="nav-label">Edit CV</span>
</a>
<a href="settings.html" class="nav-item">
<span class="nav-icon">
<svg class="nav-icon-svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</span>
<span class="nav-label">Settings</span>
</a>
<a href="about.html" class="nav-item">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</span>
<span class="nav-label">About</span>
</a>
<div class="admin-only" style="display:none">
<div class="sidebar-section-title">Admin</div>
<a href="dashboard.html" class="nav-item">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span>
<span class="nav-label">Admin Panel</span>
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="user-avatar" id="avatarInitial">U</div>
<div class="user-details">
<span class="user-name" id="user-name"></span>
<span class="user-email" id="user-email"></span>
</div>
</div>
<button class="logout-btn" id="adminToggle" onclick="toggleAdminMode()" style="display:none">
<span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span>
<span id="adminToggleLabel">Admin View</span>
</button>
<button class="logout-btn" onclick="logout()">
<span>
<svg class="nav-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>
</span>
<span>Sign Out</span>
</button>
</div>
</aside>
<div class="main-content">
<div class="top-bar">
<button class="mobile-nav-toggle" onclick="document.getElementById('sidebar').classList.toggle('open')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
</button>
<div class="top-bar-search">
<span class="search-icon">
<svg class="search-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/></svg>
</span>
<input type="text" id="globalSearch" placeholder="Search jobs..." onkeydown="if(event.key==='Enter')location.href='jobs.html?search='+encodeURIComponent(this.value)">
</div>
<div class="top-bar-actions">
<button class="btn btn-primary btn-sm" onclick="location.href='jobs.html'">Browse Jobs</button>
<button class="btn btn-primary btn-sm" onclick="location.href='make-cv.html'">Make My CV</button>
</div>
</div>
<div class="page-content">
<div class="page-header">
<div>
<h2>Dashboard</h2>
<p class="subtitle">Overview of your job search</p>
</div>
</div>
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(6,182,212,0.1);color:#06b6d4;border:1px solid rgba(6,182,212,0.15)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="7" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
</div>
<div class="stat-value" id="statTotal"></div>
<div class="stat-label">Total Jobs</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background:rgba(16,185,129,0.1);color:#10b981;border:1px solid rgba(16,185,129,0.15)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="stat-value" id="statRecent"></div>
<div class="stat-label">Last 24h</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background:rgba(245,158,11,0.1);color:#f59e0b;border:1px solid rgba(245,158,11,0.15)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
</div>
<div class="stat-value" id="statCategories"></div>
<div class="stat-label">Categories</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background:rgba(139,92,246,0.1);color:#8b5cf6;border:1px solid rgba(139,92,246,0.15)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</div>
<div class="stat-value" id="statSources"></div>
<div class="stat-label">Sources</div>
</div>
<div class="stat-card" id="lastScrapedCard" style="display:none">
<div class="stat-icon" style="background:rgba(6,182,212,0.1);color:#06b6d4;border:1px solid rgba(6,182,212,0.15)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>
</div>
<div class="stat-value stat-value-sm" id="statLastScraped"></div>
<div class="stat-label" id="statLastScrapedLabel">Last updated</div>
</div>
</div>
<div class="card">
<div class="card-title">
<div class="card-icon" style="background:rgba(6,182,212,0.08);color:#06b6d4">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/></svg>
</div>
Category Breakdown
</div>
<div id="categoryBreakdown"><p style="color:#94a3b8;font-size:13px">Loading...</p></div>
</div>
<div class="card">
<div class="card-title">
<div class="card-icon" style="background:rgba(16,185,129,0.08);color:#10b981">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/></svg>
</div>
Quick Search
</div>
<div class="search-section">
<input type="text" id="quickSearch" placeholder="Search jobs..." onkeydown="if(event.key==='Enter')location.href='jobs.html?search='+encodeURIComponent(this.value)">
<select id="quickCategory" onchange="if(this.value)location.href='jobs.html?category='+this.value">
<option value="">All Categories</option>
</select>
<button class="btn btn-primary" onclick="location.href='jobs.html?search='+encodeURIComponent(document.getElementById('quickSearch').value)">Search</button>
</div>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
<script>
async function loadStats() {
try {
const data = await apiFetch("GET", "/api/stats");
const totalEl = document.getElementById("statTotal");
const recentEl = document.getElementById("statRecent");
const catCountEl = document.getElementById("statCategories");
const srcCountEl = document.getElementById("statSources");
animateNumber(totalEl, data.total || 0);
animateNumber(recentEl, data.recent_24h || 0);
catCountEl.textContent = Object.keys(data.by_category || {}).length;
srcCountEl.textContent = Object.keys(data.by_source || {}).length;
const catContainer = document.getElementById("categoryBreakdown");
if (data.by_category && Object.keys(data.by_category).length) {
const entries = Object.entries(data.by_category).sort((a,b) => b[1]-a[1]);
const maxCount = entries[0][1];
catContainer.innerHTML = '<div class="category-breakdown">' +
entries.map(([cat, cnt]) => {
const color = CATEGORY_COLORS[cat] || "#94a3b8";
const name = CATEGORY_NAMES[cat] || cat;
const pct = maxCount > 0 ? (cnt / maxCount * 100) : 0;
return `<div class="category-chip" style="border:1px solid ${color}20">
<div class="chip-bar" style="background:${color};width:${pct}%"></div>
<span class="chip-count" style="color:${color}">${cnt}</span>
<span class="chip-label">${escHtml(name)}</span>
</div>`;
}).join("") + '</div>';
} else {
catContainer.innerHTML = '<p style="color:#94a3b8;font-size:13px">No jobs available yet. Check back after the next update.</p>';
}
const lastCard = document.getElementById("lastScrapedCard");
if (data.last_scraped) {
lastCard.style.display = "flex";
const ts = new Date(data.last_scraped.replace(" ", "T") + "Z");
const now = new Date();
const diffMs = now - ts;
const diffMins = Math.floor(diffMs / 60000);
let rel;
if (diffMins < 1) rel = "Just now";
else if (diffMins < 60) rel = diffMins + "m ago";
else if (diffMins < 1440) rel = Math.floor(diffMins / 60) + "h ago";
else rel = Math.floor(diffMins / 1440) + "d ago";
document.getElementById("statLastScraped").textContent = rel;
const newCount = data.new_24h || 0;
document.getElementById("statLastScrapedLabel").textContent =
newCount ? newCount + " new jobs" : "Last updated";
}
} catch (err) {
showToast(err.message, "error");
}
}
function animateNumber(el, target) {
const start = parseInt(el.textContent) || 0;
if (start === target) { el.textContent = target; return; }
const duration = 600;
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(start + (target - start) * eased);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
async function loadCategories() {
try {
const config = await apiFetch("GET", "/api/config");
const sel = document.getElementById("quickCategory");
(config.categories || []).forEach(c => {
const opt = document.createElement("option");
opt.value = c.slug; opt.textContent = c.name;
sel.appendChild(opt);
});
} catch (err) {}
}
if (!getToken()) window.location.href = "login.html";
updateHeader();
loadStats();
loadCategories();
</script>
</body>
</html>