Files
boc/aamos-admin-upgrade/web/assets/admin.js
T

441 lines
13 KiB
JavaScript
Raw Normal View History

/**
* 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 = `<span class="toast__icon">${TOAST_ICONS[type] || TOAST_ICONS.info}</span>
<span class="toast__msg">${escapeHtml(msg)}</span>
<button class="toast__close" aria-label="Stäng">×</button>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ─── 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 = `<td colspan="${columns.length}" style="text-align:center;opacity:.6">Inga poster</td>`;
tbody.appendChild(tr);
return;
}
rows.forEach(row => {
const tr = document.createElement('tr');
tr.innerHTML = columns.map(col => {
const val = typeof col.render === 'function'
? col.render(row[col.key], row)
: escapeHtml(row[col.key] ?? '—');
return `<td>${val}</td>`;
}).join('');
tbody.appendChild(tr);
});
}
// ─── Init ─────────────────────────────────────────────────────────────────────
async function init() {
if (!checkAuth()) return;
await fetchCurrentUser();
const usernameEl = document.getElementById('nav-username');
if (usernameEl && state.currentUser) {
usernameEl.textContent = state.currentUser.username || state.currentUser.name || '';
}
initRouter();
document.getElementById('logout-btn')?.addEventListener('click', e => {
e.preventDefault();
logout();
});
}
document.addEventListener('DOMContentLoaded', init);
// ─── Public API ───────────────────────────────────────────────────────────────
window.AAMOS = {
// state
state,
getToken,
setToken,
setCurrentUser,
// http
apiFetch,
// auth
checkAuth,
login,
logout,
fetchCurrentUser,
// navigation
setActivePage,
initRouter,
// polling
startPolling,
stopPolling,
stopAllPolling,
// ui
showToast,
formatDate,
formatRelative,
debounce,
loadingState,
escapeHtml,
serializeForm,
setFormErrors,
renderTable,
};