/* ============================================================ app.js — Core: router, sidebar, topbar, theme, search, init ============================================================ */ window.Router = {}; window.App = {}; const ROUTES = { dashboard: { title: 'Dashboard' }, inbox: { title: 'Recruitment Inbox' }, jobs: { title: 'Jobs' }, candidates: { title: 'Candidates' }, talentpool: { title: 'Talent Pool' }, pipeline: { title: 'Pipeline' }, interviews: { title: 'Interviews' }, assessments: { title: 'Assessments' }, offers: { title: 'Offers' }, managers: { title: 'Hiring Managers' }, calendar: { title: 'Calendar' }, reports: { title: 'Reports' }, analytics: { title: 'Analytics' }, notifications: { title: 'Notifications' }, settings: { title: 'Settings' }, help: { title: 'Help' }, import: { title: 'CV Import' }, jobboard: { title: 'Job Board' }, recruiterhub: { title: 'Recruiter Hub' }, aiassistant: { title: 'AI Assistant' }, aistudio: { title: 'AI Studio' }, rbac: { title: 'Access Control' }, tasks: { title: 'Tasks' } }; let currentRoute = 'dashboard'; Router.go = function (route) { if (!ROUTES[route]) route = 'dashboard'; location.hash = route; }; Router.reload = function () { Router.render(currentRoute); }; Router.render = function (route) { currentRoute = route; const view = (window.Views[route] || window.Views.dashboard)(); const main = document.getElementById('main-content'); main.innerHTML = view.html; main.scrollTop = 0; if (view.onMount) view.onMount(); // Expose the route so CSS can react to it (e.g. hide the AI launcher on // the AI Assistant page, where it sat on top of the chat send button). // Deliberately NOT `data-route`: initNav() binds a click handler to every // [data-route] element, and matching that would fire on any click. document.documentElement.setAttribute('data-view', route); // active nav document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.route === route)); document.title = 'TalentFlow · ' + (ROUTES[route] ? ROUTES[route].title : 'ATS'); // close mobile sidebar + AI dock if (App.setNavOpen) App.setNavOpen(false); else { document.getElementById('sidebar').classList.remove('mobile-open'); document.getElementById('scrim').classList.remove('open'); } const dock = document.getElementById('aiDock'); if (dock) dock.classList.remove('open'); }; function handleHash() { const route = (location.hash || '#dashboard').slice(1); Router.render(ROUTES[route] ? route : 'dashboard'); } // ---------------- Theme ---------------- // `persist` is false when the OS drives the change, so following the // system stays the default until the user makes an explicit choice. App.applyTheme = function (theme, persist) { document.documentElement.setAttribute('data-theme', theme); if (persist) { try { localStorage.setItem('tf-theme', theme); } catch (e) {} } const btn = document.getElementById('themeToggle'); if (btn) { btn.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false'); btn.setAttribute('title', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); btn.setAttribute('aria-label', btn.getAttribute('title')); } }; App.setTheme = function (theme) { App.applyTheme(theme, true); // re-render current view so canvas charts pick up new theme colors Router.render(currentRoute); }; App.toggleTheme = function () { const cur = document.documentElement.getAttribute('data-theme'); App.setTheme(cur === 'dark' ? 'light' : 'dark'); }; // ---------------- Toast passthrough ---------------- App.toast = function (msg, type, title) { UI.toast(msg, type, title); }; // ---------------- Badges ---------------- App.updateBadges = function () { const openJobs = DB.jobs.filter(j => j.status === 'Open').length; setBadge('navJobsBadge', openJobs); const unread = DB.notifications.filter(n => n.unread).length; setBadge('navNotifBadge', unread); const inboxUnread = DB.inbox.filter(i => i.unread).length + DB.emails.filter(e => e.unread).length; setBadge('navInboxBadge', inboxUnread); const openTasks = DB.tasks.filter(t => !t.done).length; setBadge('navTaskBadge', openTasks); }; function setBadge(id, n) { const el = document.getElementById(id); if (!el) return; el.textContent = n; el.style.display = n ? '' : 'none'; } App.markAllNotifsRead = function () { DB.notifications.forEach(n => n.unread = false); App.updateBadges(); App.renderNotifDropdown(); UI.toast('All notifications marked as read', 'success'); }; // ---------------- Topbar dropdown content ---------------- App.renderNotifDropdown = function () { const list = document.getElementById('notifList'); list.className = 'dd-scroll'; list.innerHTML = DB.notifications.slice(0, 6).map(n => `
${UI.icon(n.icon)}
${n.title}
${n.text}
${n.time}
`).join(''); }; App.renderMessages = function () { const list = document.getElementById('messagesList'); list.className = 'dd-scroll'; list.innerHTML = DB.messages.map(m => `
${UI.avatar(m.name, m.initials, m.color)}
${m.name}
${m.text}
${m.time} ago
`).join(''); }; // ---------------- Global search ---------------- App.search = function (q) { const box = document.getElementById('searchResults'); q = q.trim().toLowerCase(); if (!q) { box.classList.remove('open'); return; } const jobs = DB.jobs.filter(j => (j.title + j.id + j.department).toLowerCase().includes(q)).slice(0, 4); const cands = DB.candidates.filter(c => (c.name + c.email + c.jobTitle).toLowerCase().includes(q)).slice(0, 4); const mgrs = DB.managers.filter(m => m.name.toLowerCase().includes(q)).slice(0, 3); let html = ''; if (jobs.length) html += `
Jobs
` + jobs.map(j => `
${UI.icon('briefcase')}
${j.title}
${j.id} · ${j.department}
`).join(''); if (cands.length) html += `
Candidates
` + cands.map(c => `
${UI.avatar(c.name, c.initials, c.color)}
${c.name}
${c.jobTitle}
`).join(''); if (mgrs.length) html += `
Hiring Managers
` + mgrs.map(m => `
${UI.avatar(m.name, m.initials, m.color)}
${m.name}
${m.title}
`).join(''); if (!html) html = `
No results for "${q}"
`; box.innerHTML = html; box.classList.add('open'); }; App.searchGo = function (route, cb) { document.getElementById('searchResults').classList.remove('open'); document.getElementById('globalSearch').value = ''; Router.go(route); if (cb) setTimeout(cb, 120); }; // ---------------- Dropdown behavior ---------------- function initDropdowns() { document.querySelectorAll('.dropdown').forEach(dd => { const toggle = dd.querySelector('[data-dd-toggle]'); toggle.addEventListener('click', e => { e.stopPropagation(); const wasOpen = dd.classList.contains('open'); document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); if (!wasOpen) dd.classList.add('open'); }); dd.querySelector('[data-dd-panel]').addEventListener('click', e => e.stopPropagation()); }); document.addEventListener('click', () => { document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); document.getElementById('searchResults').classList.remove('open'); }); } // ---------------- Nav link interception ---------------- function initNav() { document.querySelectorAll('[data-route]').forEach(el => { el.addEventListener('click', e => { if (el.tagName === 'A') { /* href hash handles it */ } const route = el.dataset.route; if (route) { e.preventDefault(); Router.go(route); document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); } }); }); } // ---------------- Init ---------------- function init() { // Theme: an explicit past choice wins; otherwise follow the OS and keep // following it until the user picks a side themselves. const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; let saved = null; try { saved = localStorage.getItem('tf-theme'); } catch (e) {} App.applyTheme(saved || (mq && mq.matches ? 'dark' : 'light'), false); if (mq && !saved) { const onSystemChange = e => { let s = null; try { s = localStorage.getItem('tf-theme'); } catch (err) {} if (s) return; // user has chosen; stop following App.applyTheme(e.matches ? 'dark' : 'light', false); Router.render(currentRoute); // recolour canvas charts }; mq.addEventListener ? mq.addEventListener('change', onSystemChange) : mq.addListener(onSystemChange); } document.getElementById('themeToggle').addEventListener('click', App.toggleTheme); // Canvas charts are drawn at a fixed pixel size, so they blur when the // window changes width. Re-render on resize — but never while a modal or // the AI dock is open, since that would discard what the user is doing. // Width only: on iOS/Android the address bar collapsing fires `resize` with // a height change on nearly every scroll, and re-rendering there would tear // the view out from under the user mid-gesture. let resizeTimer, lastW = window.innerWidth; window.addEventListener('resize', () => { if (window.innerWidth === lastW) return; lastW = window.innerWidth; clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const busy = document.getElementById('modalRoot').classList.contains('open') || document.getElementById('aiDock').classList.contains('open'); if (!busy) Router.render(currentRoute); }, 220); }, { passive: true }); window.addEventListener('orientationchange', () => { lastW = -1; // force the next resize through }); // AI Assistant floating dock document.getElementById('aiFab').addEventListener('click', () => AI._dockOpen()); // sidebar collapse (desktop) document.getElementById('sidebarCollapse').addEventListener('click', () => { document.getElementById('sidebar').classList.toggle('collapsed'); }); // Mobile nav drawer. `nav-open` on is what CSS keys off — the FAB // sits before .scrim in the DOM, so no sibling selector can reach it, and // :has() would exclude older Safari/Firefox. App.setNavOpen = function (open) { document.getElementById('sidebar').classList.toggle('mobile-open', open); document.getElementById('scrim').classList.toggle('open', open); document.documentElement.classList.toggle('nav-open', open); // Stop the page behind the drawer from scrolling under the user's finger. document.body.style.overflow = open ? 'hidden' : ''; }; document.getElementById('mobileMenu').addEventListener('click', () => { App.setNavOpen(!document.getElementById('sidebar').classList.contains('mobile-open')); }); document.getElementById('scrim').addEventListener('click', () => App.setNavOpen(false)); // search const search = document.getElementById('globalSearch'); search.addEventListener('input', () => App.search(search.value)); search.addEventListener('click', e => e.stopPropagation()); document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); search.focus(); } if (e.key === 'Escape') { UI.closeModal(); document.getElementById('searchResults').classList.remove('open'); document.getElementById('aiDock').classList.remove('open'); App.setNavOpen(false); } }); initDropdowns(); initNav(); App.renderNotifDropdown(); App.renderMessages(); App.updateBadges(); window.addEventListener('hashchange', handleHash); handleHash(); // redraw charts on resize (debounced) let rt; window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => Router.reload(), 250); }); } document.addEventListener('DOMContentLoaded', init);