Files
ShooterHub/frontend/js/api.js
2026-04-02 11:24:30 +02:00

133 lines
4.4 KiB
JavaScript

// ── API client ────────────────────────────────────────────────────────────────
const API_BASE = '/api';
function getLang() { return localStorage.getItem('lang') || 'en'; }
function setLang(lang) { localStorage.setItem('lang', lang); }
function getAccess() { return localStorage.getItem('access'); }
function getRefresh() { return localStorage.getItem('refresh'); }
function setTokens(access, refresh) {
localStorage.setItem('access', access);
if (refresh) localStorage.setItem('refresh', refresh);
}
function clearTokens() {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
}
async function refreshAccess() {
const refresh = getRefresh();
if (!refresh) throw new Error('no refresh token');
const res = await fetch(`${API_BASE}/auth/token/refresh/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh }),
});
if (!res.ok) throw new Error('refresh failed');
const data = await res.json();
setTokens(data.access, data.refresh || refresh);
return data.access;
}
async function apiFetch(path, opts = {}) {
const headers = { ...opts.headers };
if (!(opts.body instanceof FormData)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
headers['Accept-Language'] = getLang();
const access = getAccess();
if (access) headers['Authorization'] = `Bearer ${access}`;
let res = await fetch(`${API_BASE}${path}`, { ...opts, headers });
if (res.status === 401) {
try {
const newAccess = await refreshAccess();
headers['Authorization'] = `Bearer ${newAccess}`;
res = await fetch(`${API_BASE}${path}`, { ...opts, headers });
} catch {
clearTokens();
window.location.href = '/login.html';
throw new Error('session expired');
}
}
return res;
}
// Convenience helpers ─────────────────────────────────────────────────────────
async function apiGet(path) {
const res = await apiFetch(path);
if (!res.ok) throw new Error(`GET ${path}${res.status}`);
return res.json();
}
async function apiPost(path, data) {
const body = data instanceof FormData ? data : JSON.stringify(data);
const res = await apiFetch(path, { method: 'POST', body });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const e = new Error(`POST ${path}${res.status}`);
e.data = err; e.status = res.status;
throw e;
}
return res.status === 204 ? null : res.json();
}
async function apiPatch(path, data) {
const body = data instanceof FormData ? data : JSON.stringify(data);
const res = await apiFetch(path, { method: 'PATCH', body });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const e = new Error(`PATCH ${path}${res.status}`);
e.data = err; e.status = res.status;
throw e;
}
return res.json();
}
async function apiDelete(path) {
const res = await apiFetch(path, { method: 'DELETE' });
if (!res.ok) throw new Error(`DELETE ${path}${res.status}`);
}
// Fetch ALL pages of a paginated endpoint, returning a flat array.
// path must be relative to API_BASE (e.g. '/calibers/?status=VERIFIED').
async function apiGetAll(path) {
const results = [];
let url = path;
while (url) {
const res = await apiFetch(url);
if (!res.ok) throw new Error(`GET ${url}${res.status}`);
const data = await res.json();
if (Array.isArray(data)) { results.push(...data); break; }
results.push(...(data.results || []));
if (data.next) {
// DRF returns absolute next URLs; strip origin + API_BASE to get a bare path
try {
const u = new URL(data.next);
url = u.pathname.slice(API_BASE.length) + u.search;
} catch { url = null; }
} else {
url = null;
}
}
return results;
}
// Unwrap a DRF response that may be a plain array or a paginated object
function asList(data) {
return Array.isArray(data) ? data : (data.results || []);
}
// Format API validation errors into a readable string
function formatErrors(errData) {
if (!errData || typeof errData !== 'object') return 'An error occurred.';
return Object.entries(errData)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
.join('\n');
}