6de2455917
- 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
441 lines
13 KiB
JavaScript
441 lines
13 KiB
JavaScript
/**
|
||
* 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, '&')
|
||
.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 = `<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,
|
||
};
|