Рефакторинг структуры проекта: шаблоны, статика и модули приложения
This commit is contained in:
92
static/js/admin.js
Normal file
92
static/js/admin.js
Normal file
@@ -0,0 +1,92 @@
|
||||
(function () {
|
||||
const matrixForm = document.getElementById("matrixForm");
|
||||
const matrixScroll = document.getElementById("matrixScroll");
|
||||
const matrixTable = document.getElementById("matrixTable");
|
||||
const topScroll = document.getElementById("matrixHScroll");
|
||||
const topScrollInner = document.getElementById("matrixHScrollInner");
|
||||
if (!matrixForm || !matrixScroll || !matrixTable || !topScroll || !topScrollInner) return;
|
||||
|
||||
let isDirty = false;
|
||||
let syncing = false;
|
||||
let saveTimer = null;
|
||||
let saveInFlight = false;
|
||||
|
||||
function markDirty() {
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
function updateTopScrollWidth() {
|
||||
topScrollInner.style.width = matrixTable.scrollWidth + "px";
|
||||
}
|
||||
|
||||
function syncScrollFromTop() {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
matrixScroll.scrollLeft = topScroll.scrollLeft;
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
function syncScrollFromMatrix() {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
topScroll.scrollLeft = matrixScroll.scrollLeft;
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
async function autoSaveMatrix() {
|
||||
if (saveInFlight) return;
|
||||
saveInFlight = true;
|
||||
try {
|
||||
const formData = new FormData(matrixForm);
|
||||
const response = await fetch(window.location.href, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok) throw new Error("save failed");
|
||||
isDirty = false;
|
||||
} catch (error) {
|
||||
} finally {
|
||||
saveInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
matrixForm.addEventListener("change", (event) => {
|
||||
if (!(event.target && event.target.matches('input[type="checkbox"]'))) return;
|
||||
markDirty();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(autoSaveMatrix, 250);
|
||||
});
|
||||
|
||||
matrixForm.addEventListener("submit", () => {
|
||||
isDirty = false;
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (!isDirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const anchor = event.target.closest("a");
|
||||
if (!anchor || !isDirty) return;
|
||||
const ok = window.confirm("Есть несохраненные изменения матрицы. Нажмите OK, чтобы остаться и сначала сохранить.");
|
||||
if (!ok) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target;
|
||||
if (!form || form === matrixForm || !isDirty) return;
|
||||
const ok = window.confirm("Есть несохраненные изменения матрицы. Нажмите OK, чтобы остаться и сначала сохранить.");
|
||||
if (!ok) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
topScroll.addEventListener("scroll", syncScrollFromTop);
|
||||
matrixScroll.addEventListener("scroll", syncScrollFromMatrix);
|
||||
window.addEventListener("resize", updateTopScrollWidth);
|
||||
updateTopScrollWidth();
|
||||
syncScrollFromMatrix();
|
||||
})();
|
||||
271
static/js/index.js
Normal file
271
static/js/index.js
Normal file
@@ -0,0 +1,271 @@
|
||||
const state = {
|
||||
data: { vendors: [], categories: [], products: [], product_links: [], links: [] },
|
||||
scope: "infra",
|
||||
selectedVendors: new Set(),
|
||||
selectedCategories: new Set(),
|
||||
vendorSearch: "",
|
||||
categorySearch: "",
|
||||
};
|
||||
|
||||
const el = {
|
||||
vendorList: document.getElementById("vendorList"),
|
||||
categoryList: document.getElementById("categoryList"),
|
||||
vendorSearch: document.getElementById("vendorSearch"),
|
||||
categorySearch: document.getElementById("categorySearch"),
|
||||
resultRows: document.getElementById("resultRows"),
|
||||
stats: document.getElementById("stats"),
|
||||
clearBtn: document.getElementById("clearBtn"),
|
||||
modeInfra: document.getElementById("modeInfra"),
|
||||
modeIb: document.getElementById("modeIb"),
|
||||
};
|
||||
|
||||
let clickAudioCtx = null;
|
||||
|
||||
function playScopeClick() {
|
||||
try {
|
||||
if (!clickAudioCtx) {
|
||||
clickAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
const now = clickAudioCtx.currentTime;
|
||||
const osc = clickAudioCtx.createOscillator();
|
||||
const gain = clickAudioCtx.createGain();
|
||||
osc.type = "triangle";
|
||||
osc.frequency.setValueAtTime(1800, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(950, now + 0.025);
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.linearRampToValueAtTime(0.14, now + 0.0025);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.04);
|
||||
osc.connect(gain);
|
||||
gain.connect(clickAudioCtx.destination);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.045);
|
||||
} catch (_) {
|
||||
// Ignore audio failures silently.
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(s) {
|
||||
return s.toLowerCase().replace(/ё/g, "е");
|
||||
}
|
||||
|
||||
function getMaps() {
|
||||
const productsByVendor = new Map();
|
||||
const categoriesByProduct = new Map();
|
||||
const productsByCategory = new Map();
|
||||
|
||||
for (const v of state.data.vendors) productsByVendor.set(v.id, new Set());
|
||||
for (const p of state.data.products) categoriesByProduct.set(p.id, new Set());
|
||||
for (const c of state.data.categories) productsByCategory.set(c.id, new Set());
|
||||
|
||||
for (const p of state.data.products) {
|
||||
if (!productsByVendor.has(p.vendor_id)) productsByVendor.set(p.vendor_id, new Set());
|
||||
productsByVendor.get(p.vendor_id).add(p.id);
|
||||
}
|
||||
for (const l of state.data.product_links) {
|
||||
if (!categoriesByProduct.has(l.product_id)) categoriesByProduct.set(l.product_id, new Set());
|
||||
if (!productsByCategory.has(l.category_id)) productsByCategory.set(l.category_id, new Set());
|
||||
categoriesByProduct.get(l.product_id).add(l.category_id);
|
||||
productsByCategory.get(l.category_id).add(l.product_id);
|
||||
}
|
||||
|
||||
return { productsByVendor, categoriesByProduct, productsByCategory };
|
||||
}
|
||||
|
||||
function visibleSets() {
|
||||
const { productsByVendor, categoriesByProduct, productsByCategory } = getMaps();
|
||||
|
||||
const allowedCategories = new Set(state.data.categories.map(c => c.id));
|
||||
const allowedVendors = new Set(state.data.vendors.map(v => v.id));
|
||||
const allowedProducts = new Set(state.data.products.map(p => p.id));
|
||||
|
||||
if (state.selectedVendors.size > 0) {
|
||||
const fromVendorProducts = new Set();
|
||||
for (const vId of state.selectedVendors) {
|
||||
for (const pId of (productsByVendor.get(vId) || [])) fromVendorProducts.add(pId);
|
||||
}
|
||||
for (const pId of Array.from(allowedProducts)) {
|
||||
if (!fromVendorProducts.has(pId)) allowedProducts.delete(pId);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.selectedCategories.size > 0) {
|
||||
const fromCategories = new Set();
|
||||
for (const cId of state.selectedCategories) {
|
||||
for (const pId of (productsByCategory.get(cId) || [])) fromCategories.add(pId);
|
||||
}
|
||||
for (const pId of Array.from(allowedProducts)) {
|
||||
if (!fromCategories.has(pId)) allowedProducts.delete(pId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cId of Array.from(allowedCategories)) {
|
||||
const products = productsByCategory.get(cId) || new Set();
|
||||
if (![...products].some(pId => allowedProducts.has(pId))) {
|
||||
allowedCategories.delete(cId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const vId of Array.from(allowedVendors)) {
|
||||
const products = productsByVendor.get(vId) || new Set();
|
||||
if (![...products].some(pId => allowedProducts.has(pId))) {
|
||||
allowedVendors.delete(vId);
|
||||
}
|
||||
}
|
||||
|
||||
return { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct };
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
const { allowedCategories, allowedVendors } = visibleSets();
|
||||
|
||||
const vendorQ = normalize(state.vendorSearch);
|
||||
const categoryQ = normalize(state.categorySearch);
|
||||
|
||||
el.vendorList.innerHTML = "";
|
||||
for (const vendor of state.data.vendors) {
|
||||
if (vendorQ && !normalize(vendor.name).includes(vendorQ)) continue;
|
||||
const node = document.createElement("button");
|
||||
node.className = "chip";
|
||||
if (state.selectedVendors.has(vendor.id)) node.classList.add("active");
|
||||
else if (!allowedVendors.has(vendor.id)) node.classList.add("dim");
|
||||
node.textContent = vendor.name;
|
||||
node.addEventListener("click", () => {
|
||||
if (state.selectedVendors.has(vendor.id)) state.selectedVendors.delete(vendor.id);
|
||||
else state.selectedVendors.add(vendor.id);
|
||||
render();
|
||||
});
|
||||
el.vendorList.appendChild(node);
|
||||
}
|
||||
|
||||
el.categoryList.innerHTML = "";
|
||||
const showOnlyLinkedCategories = state.selectedVendors.size > 0;
|
||||
for (const category of state.data.categories) {
|
||||
if (categoryQ && !normalize(category.name).includes(categoryQ)) continue;
|
||||
if (showOnlyLinkedCategories && !allowedCategories.has(category.id) && !state.selectedCategories.has(category.id)) continue;
|
||||
const node = document.createElement("button");
|
||||
node.className = "chip";
|
||||
if (state.selectedCategories.has(category.id)) node.classList.add("active");
|
||||
else if (!allowedCategories.has(category.id)) node.classList.add("dim");
|
||||
node.textContent = category.name;
|
||||
node.addEventListener("click", () => {
|
||||
if (state.selectedCategories.has(category.id)) state.selectedCategories.delete(category.id);
|
||||
else state.selectedCategories.add(category.id);
|
||||
render();
|
||||
});
|
||||
el.categoryList.appendChild(node);
|
||||
}
|
||||
|
||||
el.stats.textContent = `Вендоров: ${allowedVendors.size}/${state.data.vendors.length} | Категорий: ${allowedCategories.size}/${state.data.categories.length} | Продуктов: ${visibleSets().allowedProducts.size}/${state.data.products.length}`;
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
const { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct } = visibleSets();
|
||||
const productsById = new Map(state.data.products.map(p => [p.id, p]));
|
||||
|
||||
const rows = [];
|
||||
for (const vendor of state.data.vendors) {
|
||||
if (!allowedVendors.has(vendor.id)) continue;
|
||||
const productIds = [...(productsByVendor.get(vendor.id) || [])]
|
||||
.filter(pId => allowedProducts.has(pId))
|
||||
.filter(pId => {
|
||||
if (state.selectedCategories.size === 0) return true;
|
||||
const cats = categoriesByProduct.get(pId) || new Set();
|
||||
return [...state.selectedCategories].some(cId => cats.has(cId));
|
||||
});
|
||||
const products = productIds.map(pId => productsById.get(pId)).filter(Boolean);
|
||||
if (products.length === 0) continue;
|
||||
rows.push({ vendor: vendor.name, products });
|
||||
}
|
||||
|
||||
el.resultRows.innerHTML = "";
|
||||
if (rows.length === 0) {
|
||||
el.resultRows.innerHTML = '<div class="row-card"><strong>По текущим фильтрам ничего не найдено</strong></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "row-card";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = row.vendor;
|
||||
card.appendChild(title);
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "tags";
|
||||
for (const product of row.products) {
|
||||
const hasUrl = product.url && String(product.url).trim().length > 0;
|
||||
const tag = document.createElement(hasUrl ? "a" : "span");
|
||||
tag.className = "tag";
|
||||
tag.textContent = product.name;
|
||||
if (hasUrl) {
|
||||
tag.href = product.url;
|
||||
tag.target = "_blank";
|
||||
tag.rel = "noopener noreferrer";
|
||||
}
|
||||
tags.appendChild(tag);
|
||||
}
|
||||
card.appendChild(tags);
|
||||
el.resultRows.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
el.modeInfra.classList.toggle("active", state.scope === "infra");
|
||||
el.modeIb.classList.toggle("active", state.scope === "ib");
|
||||
document.body.classList.toggle("scope-ib", state.scope === "ib");
|
||||
renderChips();
|
||||
renderResults();
|
||||
}
|
||||
|
||||
async function loadScopeData(scope) {
|
||||
const res = await fetch(`/api/data?scope=${encodeURIComponent(scope)}`);
|
||||
state.data = await res.json();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadScopeData(state.scope);
|
||||
render();
|
||||
}
|
||||
|
||||
async function switchScope(scope) {
|
||||
if (scope === state.scope) return;
|
||||
playScopeClick();
|
||||
state.scope = scope;
|
||||
state.selectedVendors.clear();
|
||||
state.selectedCategories.clear();
|
||||
state.vendorSearch = "";
|
||||
state.categorySearch = "";
|
||||
el.vendorSearch.value = "";
|
||||
el.categorySearch.value = "";
|
||||
await loadScopeData(scope);
|
||||
render();
|
||||
}
|
||||
|
||||
el.vendorSearch.addEventListener("input", e => {
|
||||
state.vendorSearch = e.target.value || "";
|
||||
render();
|
||||
});
|
||||
|
||||
el.categorySearch.addEventListener("input", e => {
|
||||
state.categorySearch = e.target.value || "";
|
||||
render();
|
||||
});
|
||||
|
||||
el.clearBtn.addEventListener("click", () => {
|
||||
state.selectedVendors.clear();
|
||||
state.selectedCategories.clear();
|
||||
state.vendorSearch = "";
|
||||
state.categorySearch = "";
|
||||
el.vendorSearch.value = "";
|
||||
el.categorySearch.value = "";
|
||||
render();
|
||||
});
|
||||
|
||||
el.modeInfra.addEventListener("click", () => {
|
||||
switchScope("infra");
|
||||
});
|
||||
|
||||
el.modeIb.addEventListener("click", () => {
|
||||
switchScope("ib");
|
||||
});
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user