Files
boc/aamos-admin-upgrade/dist/assets/admin.js
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- Added GLOBAL_MARKETS_TITLE to all translation files
- Updated footer with 12 markets (4 active + 8 upcoming)
- Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi
- Built and deployed to production
- CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
2026-07-08 19:56:03 +00:00

441 lines
13 KiB
JavaScript
Raw 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.
/**
* 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,
};