BillTracker/legacy/js/app.js

180 lines
6.3 KiB
JavaScript
Raw Normal View History

2026-05-03 19:51:57 -05:00
/* ── App bootstrap & routing ── */
const PAGES = {
tracker: { container: 'page-tracker', init: (el) => TrackerPage.init(el) },
bills: { container: 'page-bills', init: (el) => BillsPage.init(el) },
categories: { container: 'page-categories', init: (el) => CategoriesPage.init(el) },
settings: { container: 'page-settings', init: (el) => SettingsPage.init(el) },
status: { container: 'page-status', init: (el) => StatusPage.init(el) },
};
let activePage = null;
let currentUser = null;
// ── Auth gate ──────────────────────────────────────────────────────────────
async function boot() {
let res;
try { res = await fetch('/api/auth/me'); }
catch { location.href = '/login.html'; return; }
if (!res.ok) { location.href = '/login.html'; return; }
const data = await res.json();
currentUser = data.user;
if (currentUser.role === 'admin') { location.href = '/admin.html'; return; }
document.getElementById('sidebar-username').textContent = currentUser.username;
if (data.single_user_mode) {
document.getElementById('sidebar-logout').style.display = 'none';
startApp();
return;
}
if (currentUser.must_change_password) { showChangePasswordOverlay(); return; }
if (currentUser.first_login) { showPrivacyNotice(); return; }
startApp();
}
// ── Password change overlay ────────────────────────────────────────────────
function showChangePasswordOverlay() {
document.getElementById('change-password-overlay').classList.remove('hidden');
document.getElementById('change-password-form').onsubmit = async (e) => {
e.preventDefault();
const np = document.getElementById('cp-new').value;
const cnf = document.getElementById('cp-confirm').value;
const err = document.getElementById('cp-error');
if (np !== cnf) { err.textContent = 'Passwords do not match.'; return; }
if (np.length < 8) { err.textContent = 'Password must be at least 8 characters.'; return; }
err.textContent = '';
try {
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_password: np }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error);
document.getElementById('change-password-overlay').classList.add('hidden');
currentUser.must_change_password = false;
currentUser.first_login ? showPrivacyNotice() : startApp();
} catch (ex) {
document.getElementById('cp-error').textContent = 'Error: ' + ex.message;
}
};
}
// ── Privacy notice overlay ─────────────────────────────────────────────────
function showPrivacyNotice() {
document.getElementById('privacy-overlay').classList.remove('hidden');
document.getElementById('privacy-ack-btn').onclick = async () => {
await fetch('/api/auth/acknowledge-privacy', { method: 'POST' });
document.getElementById('privacy-overlay').classList.add('hidden');
currentUser.first_login = false;
startApp();
};
}
// ── Main app ───────────────────────────────────────────────────────────────
function startApp() {
document.getElementById('app').style.visibility = 'visible';
setupLogout();
setupNavLinks();
handleHash();
}
function setupLogout() {
document.getElementById('sidebar-logout').addEventListener('click', async (e) => {
e.preventDefault();
await fetch('/api/auth/logout', { method: 'POST' });
location.href = '/login.html';
});
}
function setupNavLinks() {
document.querySelectorAll('.nav-link[data-page]').forEach(link => {
link.addEventListener('click', () => { activePage = null; navigate(link.dataset.page); });
});
window.addEventListener('hashchange', () => { activePage = null; handleHash(); });
}
function handleHash() {
navigate(location.hash.replace('#', '') || 'tracker');
}
function navigate(page) {
if (!PAGES[page]) page = 'tracker';
document.querySelectorAll('.nav-link[data-page]').forEach(a => {
a.classList.toggle('active', a.dataset.page === page);
});
document.querySelectorAll('.page').forEach(el => el.classList.remove('active'));
document.getElementById(PAGES[page].container).classList.add('active');
if (activePage !== page) {
activePage = page;
PAGES[page].init(document.getElementById(PAGES[page].container));
}
}
// ── Toast system ───────────────────────────────────────────────────────────
const TOAST_ICONS = {
success: '✓',
error: '✕',
warning: '⚠',
info: '',
};
let toastContainer = null;
function getToastContainer() {
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
document.body.appendChild(toastContainer);
}
return toastContainer;
}
function showToast(msg, type = 'info', duration = 3500) {
const container = getToastContainer();
const toast = document.createElement('div');
const icon = TOAST_ICONS[type] || TOAST_ICONS.info;
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<span class="toast-icon">${icon}</span>
<span class="toast-msg">${msg}</span>
<button class="toast-close" aria-label="Dismiss">&times;</button>
<div class="toast-bar"></div>
`;
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => toast.classList.add('toast-show'));
const dismiss = () => {
toast.classList.remove('toast-show');
toast.classList.add('toast-hide');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
};
const timer = setTimeout(dismiss, duration);
toast.querySelector('.toast-close').onclick = () => { clearTimeout(timer); dismiss(); };
}
// Hide app shell until auth check completes (prevents flash)
document.getElementById('app').style.visibility = 'hidden';
boot();