| <!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> |
|
|