/** * AAMOS Admin Panel — core JS */ 'use strict'; // ─── API base URL ──────────────────────────────────────────────────────────── const API_BASE = (() => { if (typeof window.__AAMOS_API_BASE !== 'undefined') return window.__AAMOS_API_BASE; const stored = localStorage.getItem('aamos_api_base'); if (stored) return stored.replace(/\/$/, ''); return window.location.origin + '/api'; })(); // ─── State ─────────────────────────────────────────────────────────────────── const state = { currentUser: null, authToken: localStorage.getItem('aamos_token') || null, _polls: new Map(), }; // ─── Auth helpers ───────────────────────────────────────────────────────────── function getToken() { return state.authToken || localStorage.getItem('aamos_token'); } function setToken(token) { state.authToken = token; if (token) { localStorage.setItem('aamos_token', token); } else { localStorage.removeItem('aamos_token'); } } function setCurrentUser(user) { state.currentUser = user; } // ─── HTTP client ───────────────────────────────────────────────────────────── async function apiFetch(path, options = {}) { const token = getToken(); const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.headers || {}), }; const res = await fetch(`${API_BASE}${path}`, { ...options, headers, }); if (res.status === 401) { logout(); return null; } if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(body || `HTTP ${res.status}`); } const ct = res.headers.get('content-type') || ''; return ct.includes('application/json') ? res.json() : res.text(); } // ─── Auth flow ──────────────────────────────────────────────────────────────── function checkAuth() { const token = getToken(); const isLoginPage = window.location.pathname.endsWith('login.html') || window.location.pathname.endsWith('login'); if (!token) { if (!isLoginPage) { const next = encodeURIComponent(window.location.href); window.location.href = `/login.html?next=${next}`; } return false; } if (isLoginPage) { window.location.href = '/index.html'; return false; } return true; } async function login(username, password) { const data = await apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }), }); if (!data) return false; setToken(data.token); setCurrentUser(data.user || { username }); return true; } function logout() { stopAllPolling(); setToken(null); state.currentUser = null; window.location.href = '/login.html'; } async function fetchCurrentUser() { try { const user = await apiFetch('/auth/me'); if (user) setCurrentUser(user); return user; } catch { return null; } } // ─── Navigation ─────────────────────────────────────────────────────────────── function setActivePage(pageId) { document.querySelectorAll('[data-page]').forEach(el => { el.classList.toggle('hidden', el.dataset.page !== pageId); }); document.querySelectorAll('[data-nav]').forEach(el => { el.classList.toggle('active', el.dataset.nav === pageId); }); history.pushState({ page: pageId }, '', `#${pageId}`); } function initRouter() { const hash = window.location.hash.replace('#', ''); if (hash) setActivePage(hash); window.addEventListener('popstate', e => { const page = e.state?.page || window.location.hash.replace('#', ''); if (page) setActivePage(page); }); document.querySelectorAll('[data-nav]').forEach(el => { el.addEventListener('click', e => { e.preventDefault(); setActivePage(el.dataset.nav); }); }); } // ─── Polling ───────────────────────────────────────────────────────────────── function startPolling(endpoint, callback, intervalMs = 10_000) { if (state._polls.has(endpoint)) stopPolling(endpoint); const tick = async () => { try { const data = await apiFetch(endpoint); if (data !== null) callback(data); } catch (err) { console.warn(`[poll] ${endpoint}:`, err.message); } }; tick(); const id = setInterval(tick, intervalMs); state._polls.set(endpoint, id); return () => stopPolling(endpoint); } function stopPolling(endpoint) { const id = state._polls.get(endpoint); if (id !== undefined) { clearInterval(id); state._polls.delete(endpoint); } } function stopAllPolling() { state._polls.forEach(id => clearInterval(id)); state._polls.clear(); } // ─── Toast notifications ────────────────────────────────────────────────────── const TOAST_TYPES = { success: 'toast--success', error: 'toast--error', warning: 'toast--warning', info: 'toast--info', }; const TOAST_ICONS = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ', }; function getOrCreateToastContainer() { let container = document.getElementById('toast-container'); if (!container) { container = document.createElement('div'); container.id = 'toast-container'; container.setAttribute('role', 'alert'); container.setAttribute('aria-live', 'polite'); Object.assign(container.style, { position: 'fixed', bottom: '1.5rem', right: '1.5rem', zIndex: '9999', display: 'flex', flexDirection: 'column', gap: '0.5rem', }); document.body.appendChild(container); } return container; } function showToast(msg, type = 'info', durationMs = 4000) { const container = getOrCreateToastContainer(); const toast = document.createElement('div'); toast.className = `toast ${TOAST_TYPES[type] || TOAST_TYPES.info}`; toast.innerHTML = `${TOAST_ICONS[type] || TOAST_ICONS.info} ${escapeHtml(msg)} `; Object.assign(toast.style, { display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.75rem 1rem', borderRadius: '0.375rem', background: typeToColor(type), color: '#fff', boxShadow: '0 4px 12px rgba(0,0,0,.2)', minWidth: '240px', maxWidth: '400px', fontSize: '0.9rem', opacity: '0', transform: 'translateX(2rem)', transition: 'opacity 0.2s, transform 0.2s', }); const dismiss = () => { toast.style.opacity = '0'; toast.style.transform = 'translateX(2rem)'; setTimeout(() => toast.remove(), 220); }; toast.querySelector('.toast__close').addEventListener('click', dismiss); container.appendChild(toast); requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateX(0)'; }); if (durationMs > 0) setTimeout(dismiss, durationMs); return dismiss; } function typeToColor(type) { return { success: '#16a34a', error: '#dc2626', warning: '#d97706', info: '#2563eb' }[type] || '#2563eb'; } // ─── Date / time formatters ─────────────────────────────────────────────────── const DATE_FMT = new Intl.DateTimeFormat('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); function formatDate(ts) { if (!ts) return '—'; const d = ts instanceof Date ? ts : new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts); return isNaN(d) ? String(ts) : DATE_FMT.format(d); } function formatRelative(ts) { if (!ts) return '—'; const d = ts instanceof Date ? ts : new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts); if (isNaN(d)) return String(ts); const diffMs = Date.now() - d.getTime(); const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffH = Math.floor(diffMin / 60); const diffD = Math.floor(diffH / 24); if (diffSec < 5) return 'just nu'; if (diffSec < 60) return `${diffSec}s sedan`; if (diffMin < 60) return `${diffMin} min sedan`; if (diffH < 24) return `${diffH} tim sedan`; if (diffD < 7) return `${diffD} d sedan`; return formatDate(d); } // ─── UI utilities ───────────────────────────────────────────────────────────── function debounce(fn, waitMs = 300) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), waitMs); }; } function loadingState(btn, active) { if (!btn) return; if (active === undefined) { return btn.dataset.loading === 'true'; } if (active) { btn.dataset.loading = 'true'; btn.dataset.originalText = btn.textContent; btn.disabled = true; btn.textContent = btn.dataset.loadingText || 'Laddar…'; } else { btn.dataset.loading = 'false'; btn.disabled = false; if (btn.dataset.originalText) btn.textContent = btn.dataset.originalText; } } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ─── Form helpers ───────────────────────────────────────────────────────────── function serializeForm(form) { return Object.fromEntries(new FormData(form)); } function setFormErrors(form, errors = {}) { form.querySelectorAll('.field-error').forEach(el => el.textContent = ''); Object.entries(errors).forEach(([field, msg]) => { const el = form.querySelector(`[data-error="${field}"]`); if (el) el.textContent = msg; }); } // ─── Render helpers ─────────────────────────────────────────────────────────── function renderTable(tableEl, rows, columns) { const tbody = tableEl.querySelector('tbody') || tableEl; tbody.innerHTML = ''; if (!rows.length) { const tr = document.createElement('tr'); tr.innerHTML = `