/***********************************************************************
* access-token хранится только в памяти (без localStorage)
**********************************************************************/
let accessToken = null;
/***********************************************************************
* user объект в котором хранятся данные пользователя
**********************************************************************/
const user = {
id: 0,
name: ""
}
/***********************************************************************
* apiProtected — это удобная функция для защищённых API-запросов,
* которая:
*
* - Подставляет стандартные и пользовательские настройки запроса.
* - Добавляет Authorization с токеном.
* - Автоматически сериализует JSON-тело.
* - Парсит ответ.
* - Обрабатывает устаревший токен (401) и повторяет запрос.
* - Выбрасывает ошибки для внешнего try...catch.
***********************************************************************/
async function apiProtected(path, options = {}) {
// Базовые настройки
const defaultOptions = {
//method: "GET",
headers: { "Content-Type": "application/json" },
credentials: "include"
};
// Объединяем настройки
const finalOptions = {
...defaultOptions,
...options,
headers: { ...defaultOptions.headers, ...(options.headers || {}) }
};
// Если есть тело и это объект — сериализуем
if (finalOptions.body && typeof finalOptions.body === "object") {
finalOptions.body = JSON.stringify(finalOptions.body);
}
// Вспомогательная функция отправки запроса
const send = async () => {
try {
// Добавляем Authorization, если токен есть
if (accessToken) {
finalOptions.headers.Authorization = `Bearer ${accessToken}`;
}
// Отправляем fetch запрос.
const res = await fetch(path, finalOptions);
const text = await res.text();
let data;
// Пытаемся распарсить ответ как JSON, если не получается
// — возвращаем текст.
try {
data = JSON.parse(text);
} catch {
data = text;
}
return { res, data };
} catch (err) {
return { res: null, data: err.toString() };
}
};
// Первый запрос
let { res, data } = await send();
// Если 401 — обновляем токен и повторяем
if (res && res.status === 401) {
await refreshAccess(); // обновляем accessToken
({ res, data } = await send()); // повторный запрос
}
// Если всё равно ошибка — кидаем
if (!res || !res.ok) {
throw { status: res ? res.status : 0, data };
}
return data; // возвращаем распарсенный JSON или текст
}
/***************************************************************************
* refreshAccess() Обнавление токенов:
*
* - Отправляет POST на /api/users/refresh, используя refresh-токен в cookie.
* - Проверяет успешность ответа.
* - Сохраняет новый access-токен.
* - Декодирует токен, чтобы получить user_id.
* - Обновляет глобальные данные о пользователе (id и name).
****************************************************************************/
async function refreshAccess (){
//Отправка запроса на обновление токена
const rr = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include"
});
// Проверка ответа
if (!rr.ok) throw "refresh failed";
// Получение нового токена
const j = await rr.json();
accessToken = j.access_token;
// Декодирование payload JWT
const payload = JSON.parse(
atob(accessToken.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
);
// Обновление данных пользователя
user.id = payload.user_id;
user.name = (await getUserDataByID(user.id)).name;
}
/********************************************************************************
* loadMenu функция загрузки блока меню страницы в формате Markdown
********************************************************************************/
async function loadMenu() {
await loadBlock("menu/top1", "header");
}
/********************************************************************************
* loadPage функция загрузки блока страницы в формате Markdown
********************************************************************************/
async function loadPage(path) {
await loadBlock(path, "content");
}
/*********************************************************************************
* loadMdScript функция загрузки Markdown библиотеки
*********************************************************************************/
function loadMdScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
document.head.appendChild(script);
});
}
/**********************************************************************************
* loadBlock — это универсальная функция для динамического контента:
*
* - Находит контейнер по id.
* - Очищает старый контент и связанные скрипты/стили.
* - Запрашивает блок через apiProtected.
* - Преобразует Markdown в HTML.
* - Добавляет CSS и JS динамически.
* - Вызывает pageInit() блока, если есть.
* - Обрабатывает ошибки.
**********************************************************************************/
async function loadBlock(path, block_Name) {
// Получаем контейнер блока
const container = document.getElementById(block_Name);
if (!container) {
return;
}
// Обработка пути
path = path.replace(/\/$/, "");
if (!container) {
console.error(`loadBlock ERROR: element #${block_Name} not found`);
return;
}
const blockName = path === "pages" ? "pages/home" : path;
try {
// Очистка контейнера и старых динамических стилей/скриптов
container.innerHTML = '';
document.querySelectorAll('style[data-dynamic], script[data-dynamic]').forEach(el => {
const name = el.getAttribute('data-dynamic');
if (name === block_Name || !document.getElementById(name)) {
el.remove();
}
});
// Получение блока с сервера
const response = await apiProtected(`/api/block/${blockName}`, {method: "GET"});
// Динамически подгружаем markdown-it, если он ещё не загружен
if (!window.markdownit) {
await loadMdScript('/static/js/markdown-it.min.js');
}
const { content: mdContent, css, js } = response;
// Преобразуем markdown в HTML
if (mdContent) {
const md = window.markdownit({ html: true, linkify: true, typographer: true });
container.innerHTML = md.render(mdContent);
container?.id?.match(/^loadedBlock_\d+_view$/) && (document.getElementById(container.id.replace('_view', '_html')).innerHTML = mdContent);
}
// Добавление CSS блока
if (css) {
const style = document.createElement('style');
style.dataset.dynamic = block_Name;
style.textContent = css;
document.head.appendChild(style);
}
// Добавление JS блока
if (js) {
const script = document.createElement('script');
script.dataset.dynamic = block_Name;
script.textContent = `
(() => {
try {
${js}
if (typeof pageInit === "function") pageInit();
} catch (e) {
console.error("Block script error:", e);
}
})();
`;
document.body.appendChild(script);
}
// Обработка ошибок
} catch (err) {
console.error(err);
container.innerHTML = "
блок не найден
";
}
}
/*****************************************************************************
* SPA-навигация
*****************************************************************************/
function navigateTo(url, target) {
const clean = url.replace(/^\//, "");
history.pushState({}, "", "/" + clean);
loadBlock("pages/" + clean, target);
}
/*****************************************************************************
* Поддержка кнопки "назад/вперед"
*****************************************************************************/
window.addEventListener("popstate", () => {loadBlock(location.pathname);});
/*****************************************************************************
* Обработка истории браузера
*****************************************************************************/
window.addEventListener("popstate", () => loadBlock(window.location.pathname));
/*****************************************************************************
* Инициализация после загрузки DOM
*****************************************************************************/
window.onload = async function () {
let url = window.location.href;
// Убираем слеш в конце, если он есть
if (url.endsWith("/")) {
url = url.slice(0, -1);
// Меняем URL в адресной строке без перезагрузки страницы
window.history.replaceState(null, "", url);
}
//console.assert("читаем меню")
await loadMenu();
await loadPage("pages"+window.location.pathname);
};
/*****************************************************************************
* Перехватчик ссылок
*****************************************************************************/
window.addEventListener("click", (event) => {
const a = event.target.closest("a");
if (!a) return;
const href = a.getAttribute("href");
// игнорируем внешние ссылки и mailto:
if (!href || href.startsWith("http") || href.startsWith("mailto:")) return;
const target = a.dataset.target || "content"; // default = content
event.preventDefault();
navigateTo(href, target);
});
/*****************************************************************************
* Переключение видимости пароля
*****************************************************************************/
document.addEventListener("click", (e) => {
if (!e.target.classList.contains("toggle-pass")) return;
console.log("toggle");
const input = e.target.previousElementSibling;
if (!input) return;
if (input.type === "password") {
input.type = "text";
e.target.textContent = "*";//🔓
} else {
input.type = "password";
e.target.textContent = "A";//🔒
}
});
/*****************************************************************************
* Получение данных пользователя. Пример использования:
* btn.onclick = async function () {
* const user = await getUserDataByID(3);
* alert(user.name);
* };
*****************************************************************************/
async function getUserDataByID(id) {
const data = await apiProtected(
`/api/users/getUserData?userid=${encodeURIComponent(id)}&by=id`
);
return {
id: data.ID,
name: data.Username
}
}
/******************************************************************************
* Функция userLogin:
*
* - пытается залогиниться через API,
* - возвращает accessToken при успехе,
* - бросает понятные ошибки (INVALID_CREDENTIALS, LOGIN_FAILED) при неудаче.
******************************************************************************/
async function userLogin(username, password) {
try {
// Запрос логина
const r = await apiProtected(`/api/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username,
password
})
});
// Проверка access token
if (!r?.access_token) {
throw new Error("Token not received");
}
const payload = JSON.parse(
atob(r.access_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
);
// Успешный результат
user.name = username;
user.id = payload.user_id;
return {
accessToken: r.access_token
};
// Обработка ошибок (catch)
} catch (err) {
// err — объект { status, data } из apiProtected
if (err?.status === 401) {
throw new Error("INVALID_CREDENTIALS");
}
// Неверный логин / пароль
console.error("Login error:", err);
throw new Error("LOGIN_FAILED");
}
}
/******************************************************************************
* userLogout — это функция выхода пользователя из системы.
******************************************************************************/
async function userLogout() {
accessToken = "";
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
};
/******************************************************************************
* userRegister функция которая:
*
* - регистрирует нового пользователя,
* - возвращает ответ сервера при успехе,
* - преобразует HTTP-ошибки в бизнес-ошибки, понятные UI.
******************************************************************************/
async function userRegister(username, password) {
try {
// Запрос регистрации
const data = await apiProtected("/api/auth/register", {
method: "POST",
body: {
username,
password
}
});
// Успешный результат
return data;
// Перехват ошибок
} catch (err) {
// Сюда прилетают ошибки, брошенные apiProtected:
// Логирование
console.error("Register error:", err);
// Маппинг HTTP → бизнес-ошибки
// Некорректные данные
if (err?.status === 400) {
throw new Error("BAD_REQUEST");
}
// Пользователь уже существует
if (err?.status === 409) {
throw new Error("USER_EXISTS");
}
// Любая другая ошибка
throw new Error("REGISTER_FAILED");
}
}
/***************************************************************************
* Класса UIComponents это статический UI-helper, который:
*
* - не хранит состояние приложения
* - не зависит от фреймворков
* - создаёт всплывающие UI-элементы поверх страницы
*
* Содержит методы:
*
* - UIComponents.showAlert(...)
* - UIComponents.confirm(...)
* - UIComponents.showPopupList(...)
* - UIComponents.showFileProperties(...)
***************************************************************************/
class UIComponents {
/* ============== 1. АЛЕРТ С ОВЕРЛЕЕМ ============== */
/* Показывает модальное окно с кнопкой OK.
с затемняющим фоном, который перекрывает страницу*/
static showAlert(message) {//, title = 'Сообщение'
const overlay = document.createElement('div');
overlay.className = 'ui-overlay';
const alertBox = document.createElement('div');
alertBox.className = 'window-popup';//ui-alert
alertBox.innerHTML =
//${title}
`${message}
`;
alertBox.querySelector('button').onclick = () => {
overlay.remove();
};
overlay.appendChild(alertBox);
document.body.appendChild(overlay);
}
/*==================== 2. confirm ===================== */
/* Аналог window.confirm */
static confirm(message, title = 'Подтверждение') {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'ui-overlay';
const box = document.createElement('div');
box.className = 'ui-alert';
box.innerHTML = `
${title}
${message}
`;
const close = (result) => {
overlay.remove();
resolve(result);
};
box.querySelector('[data-yes]').onclick = () => close(true);
box.querySelector('[data-no]').onclick = () => close(false);
overlay.appendChild(box);
document.body.appendChild(overlay);
});
}
/* ========== 3. ПОПАП СПИСОК ========== */
static showPopupList(items = {}, x = 0, y = 0) {
// Удаляем предыдущий popup
if (UIComponents.currentPopup) {
UIComponents.currentPopup.remove();
UIComponents.currentPopup = null;
}
const popup = document.createElement('div');
popup.className = 'ui-popup-list';
popup.style.left = x + 'px';
popup.style.top = y + 'px';
for (const [name, fn] of Object.entries(items)) {
const el = document.createElement('div');
el.textContent = name;
el.onclick = () => {
fn(); // вызываем конкретную функцию
popup.remove();
UIComponents.currentPopup = null;
};
popup.appendChild(el);
}
document.body.appendChild(popup);
UIComponents.currentPopup = popup;
const removePopup = () => {
if (UIComponents.currentPopup) {
UIComponents.currentPopup.remove();
UIComponents.currentPopup = null;
}
document.removeEventListener('click', removePopup);
};
setTimeout(() => document.addEventListener('click', removePopup), 0);
}
/* ========== 4. ОКНО "СВОЙСТВА ФАЙЛА" ========== */
static showFileProperties(general = {}, details = {}) {
const overlay = document.createElement('div');
overlay.className = 'ui-overlay';
const win = document.createElement('div');
win.className = 'ui-window';
win.style.position = 'absolute';
const rows = obj =>
Object.entries(obj)
.map(([k, v]) => `${k}${v}
`)
.join('');
win.innerHTML = `
${rows(general)}
${rows(details)}
`;
win.querySelector('button').onclick = () => overlay.remove();
/* tabs */
win.querySelectorAll('.tab').forEach(tab => {
tab.onclick = () => {
win.querySelectorAll('.tab, .tab-content')
.forEach(e => e.classList.remove('active'));
tab.classList.add('active');
win.querySelector('#' + tab.dataset.tab).classList.add('active');
};
});
/* drag */
const header = win.querySelector('.header');
header.onmousedown = (e) => {
const r = win.getBoundingClientRect();
const dx = e.clientX - r.left;
const dy = e.clientY - r.top;
document.onmousemove = e =>
Object.assign(win.style, {
left: e.clientX - dx + 'px',
top: e.clientY - dy + 'px'
});
document.onmouseup = () => document.onmousemove = null;
};
overlay.appendChild(win);
document.body.appendChild(overlay);
win.style.left = 'calc(50% - 180px)';
win.style.top = '20%';
}
}