add blocks

This commit is contained in:
2025-12-17 10:14:13 +02:00
parent 18a31be0b1
commit d78a6bedd5
54 changed files with 2755 additions and 10 deletions

173
static/base/css/style.css Normal file
View File

@@ -0,0 +1,173 @@
body {
font-family: system-ui, sans-serif;
margin: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
background: #333;
color: white;
padding: 10px 15px;
position: relative;
}
footer {
background: #333;
color: white;
text-align: center;
padding: 10px;
}
main {
flex: 1;
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
/* навигация */
nav ul {
list-style: none;
display: flex;
gap: 20px;
margin: 0;
padding: 0;
}
nav a {
color: white;
text-decoration: none;
font-weight: 500;
}
nav a:hover {
text-decoration: underline;
}
/* бургер */
.burger {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 30px;
height: 25px;
background: none;
border: none;
cursor: pointer;
}
.burger span {
display: block;
height: 3px;
width: 100%;
background: white;
border-radius: 2px;
transition: 0.3s;
}
.burger.active span:nth-child(1) {
transform: translateY(8px) rotate(45deg);
}
.burger.active span:nth-child(2) {
opacity: 0;
}
.burger.active span:nth-child(3) {
transform: translateY(-8px) rotate(-45deg);
}
/* сетка */
.grid-3 {
display: grid;
grid-template-columns: 15% 70% 15%;
gap: 20px;
margin-top: 20px;
}
.grid-block {
background: #f5f5f5;
padding: 15px;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
/* Полупрозрачный фон */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
/* Окна */
/* Message */
.window-popup { width:400px; height:150px; background:#fff; border-radius:10px; display:flex; flex-direction:column; justify-content:center; align-items:center; padding:20px; box-shadow:0 0 10px rgba(0,0,0,0.3); }
.window-popup button { margin-top:20px; padding:5px 10px; cursor:pointer; }
/* Menu */
.window-menu { position:absolute; background:#fff; border-radius:10px; border:1px solid rgba(0,0,0,0.12); box-shadow:0 6px 18px rgba(0,0,0,0.12); min-width:160px; z-index:9999; overflow:hidden; }
.window-item { padding:8px 12px; cursor:pointer; white-space:nowrap; font-size:14px; }
.window-item:hover { background:rgba(0,0,0,0.04); }
/* File */
.window-panel { width:420px; background:#f0f0f0; border:1px solid #888; border-radius:10px; box-shadow:0 0 12px rgba(0,0,0,0.4); position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); user-select:none; display:flex; flex-direction:column; }
.window-header { background:#f0f0f0; padding:10px; font-size:16px; font-weight:600; border-bottom:1px solid #aaa; cursor:move; }
.window-tabs { display:flex; border-bottom:1px solid #aaa; background:#f0f0f0; }
.window-tab { padding:8px 14px; cursor:pointer; border-right:1px solid #aaa; user-select:none; }
.window-tab.active { background:#fff; font-weight:600; border-bottom:2px solid #fff; }
.window-content { display:none; padding:15px; background:#fff; }
.window-content.active { display:block; }
.window-row { display:flex; justify-content:space-between; margin-bottom:8px; font-size:14px; }
.window-buttons { display:flex; justify-content:flex-end; padding:10px; gap:8px; border-top:1px solid #aaa; background:#f0f0f0; }
.window-buttons button { padding:6px 16px; cursor:pointer; }
/* адаптив */
@media (max-width: 425px) {
.grid-3 {
grid-template-columns: 1fr;
}
.burger {
display: flex;
}
nav {
position: absolute;
top: 100%;
left: 0;
width: 100%;
background: #222;
display: none;
flex-direction: column;
text-align: center;
padding: 10px 0;
z-index: 10;
}
nav.open {
display: flex;
animation: slideDown 0.3s ease;
}
nav ul {
flex-direction: column;
gap: 10px;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
}

BIN
static/base/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

292
static/base/js/app.js Normal file
View File

@@ -0,0 +1,292 @@
/***********************************************************************
* ГЛОБАЛЬНОЕ СОСТОЯНИЕ
**********************************************************************/
let accessToken = null; // access-token хранится только в памяти (без localStorage)
/***********************************************************************
* sendRequest УНИВЕРСАЛЬНАЯ ФУНКЦИЯ ДЛЯ ОТПРАВКИ HTTP-ЗАПРОСОВ
*
* sendRequest(url, options)
* - автоматически добавляет Content-Type и credentials
* - автоматически превращает body в JSON
* - проверяет response.ok
* - пробрасывает текст ошибки
* - возвращает JSON-ответ
**********************************************************************/
//let accessToken = ""; // access только в памяти
async function apiProtected(path, options = {}) {
const send = async () =>
fetch(path, {
...options,
headers: {
...(options.headers || {}),
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json"
}
});
let r = await send();
if ((r.status === 401)) {
// обновляем access
const rr = await fetch("/api/users/refresh", {
method: "POST",
credentials: "include"
});
if (!rr.ok) throw "refresh failed";
const j = await rr.json();
accessToken = j.access_token;
r = await send();
}
if (!r.ok) throw await r.text();
return r.json();
}
async function sendRequest(url, options = {}) {
// Базовые параметры
const defaultOptions = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
// Объединяем настройки
const finalOptions = {
...defaultOptions,
...options,
headers: { ...defaultOptions.headers, ...(options.headers || {}) }
};
// Если тело — объект, превращаем в JSON
if (finalOptions.body && typeof finalOptions.body === "object") {
finalOptions.body = JSON.stringify(finalOptions.body);
}
let response;
try {
response = await fetch(url, finalOptions);
} catch (err) {
// Сетевые ошибки (сервер не доступен, нет интернета, CORS и т.д.)
return {
ok: false,
status: 0,
data: err.toString()
};
}
// Читаем тело ответа только один раз
let text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = text;
}
return {
ok: response.ok,
status: response.status,
data
};
}
/********************************************************************
* 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 функция загрузки блока в формате Markdown *
********************************************************************/
async function loadBlock(path, block_Name) {
const container = document.getElementById(block_Name);
if (!container) {
//console.warn(`loadBlock: контейнер #${block_Name} не найден — игнорируем`);
return;
}
path = path.replace(/\/$/, "");
//console.log(path);
if (!container) {
console.error(`loadBlock ERROR: element #${block_Name} not found`);
return;
}
const blockName = path === "pages" ? "pages/home" : path;
//console.log(blockName);
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 sendRequest(`/api/block/${blockName}`);
if (!response.ok) {
throw new Error(`Failed to load block: ${response.status}`);
}
// Динамически подгружаем markdown-it, если он ещё не загружен
if (!window.markdownit) {
await loadMdScript('/static/js/markdown-it.min.js');
}
const { content: mdContent, css, js } = response.data;
// Преобразуем markdown в HTML
if (mdContent) {
const md = window.markdownit({ html: true, linkify: true, typographer: true });
container.innerHTML = md.render(mdContent);
}
if (css) {
const style = document.createElement('style');
style.dataset.dynamic = block_Name;
style.textContent = css;
document.head.appendChild(style);
}
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 = "<h2>блок не найден</h2>";
}
}
// 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);
});
//popup
function popup(message, afterClose){
// Создаём overlay
const overlay = document.createElement('div');
overlay.className = 'overlay';
// Создаём popup
const popup = document.createElement('div');
popup.className = 'popup';
// Добавляем текст
const text = document.createElement('div');
text.textContent = message;
// Добавляем кнопку закрытия
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Закрыть';
closeBtn.addEventListener('click', () => {
overlay.remove();
if (typeof afterClose === 'function') {
afterClose(); // ← ВАША ФУНКЦИЯ ПОСЛЕ ЗАКРЫТИЯ
}
});
}
//popupMenu
function popupMenu(message, afterClose){
// Создаём popupMenu
const popupMenu = document.createElement('div');
popup.className = 'popup_Menu';
// Добавляем текст
const text = document.createElement('div');
text.textContent = message;
// Добавляем кнопку закрытия
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Закрыть';
closeBtn.addEventListener('click', () => {
overlay.remove();
if (typeof afterClose === 'function') {
afterClose(); // ← ВАША ФУНКЦИЯ ПОСЛЕ ЗАКРЫТИЯ
}
});
popup.appendChild(text);
popup.appendChild(closeBtn);
overlay.appendChild(popup);
document.body.appendChild(overlay);
};
/* ------------------- Переключение видимости пароля ------------------- */
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 = "👁";
}
});

2
static/base/js/markdown-it.min.js vendored Normal file

File diff suppressed because one or more lines are too long

24
static/base/main.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<link rel="icon" type="image/png" href="/static/img/favicon.png">
<title>JWT SPA project</title>
<link rel="stylesheet" href="/static/css/style.css">
<script defer src="/static/js/app.js"></script>
</head>
<body id="body">
<header>
<div id="header"></div>
</header>
<main id="content"></main>
<footer>
<p>© 2025 Go Markdown SPA</p>
</footer>
</body>
</html>