BillTracker/legacy/js/app.js

180 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ── 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();