refactor: Extract teamWheel component (Phase 4 start)
- Created static/js/components/teamWheel.js (329 lines) - Replaced 207-line inline script in department_scripts.html with ES6 module import - Features: auto-scroll, manual control (mouse/touch/wheel), member focus, fallback - All 12 tests passing
This commit is contained in:
parent
4d04aafdb9
commit
8c1e4f17a0
@ -24,7 +24,7 @@ export {
|
||||
// export { CalendarWidget } from './calendarWidget.js';
|
||||
|
||||
// Компоненты отображения (будут созданы в Фазе 4)
|
||||
// export { TeamWheel } from './teamWheel.js';
|
||||
export { initTeamWheel } from './teamWheel.js';
|
||||
|
||||
// Версия компонентов
|
||||
export const COMPONENTS_VERSION = '1.0.0';
|
||||
|
||||
307
backend/static/js/components/teamWheel.js
Normal file
307
backend/static/js/components/teamWheel.js
Normal file
@ -0,0 +1,307 @@
|
||||
/**
|
||||
* TeamWheel - интерактивный виджет команды отдела с автоскроллом
|
||||
*
|
||||
* Визуализирует членов команды в виде вращающегося колеса с 4 колонками,
|
||||
* отсортированными разными способами. Поддерживает:
|
||||
* - Автоматический скролл с изменением направления
|
||||
* - Ручной контроль (мышь/тач/колесико)
|
||||
* - Клик по аватару для фокусировки на сотруднике
|
||||
* - Fallback на руководителя при пустой команде
|
||||
*
|
||||
* @module components/teamWheel
|
||||
*/
|
||||
|
||||
/**
|
||||
* Инициализирует TeamWheel виджет
|
||||
*
|
||||
* @param {Object} options - Параметры конфигурации
|
||||
* @param {string} options.wheelId - ID элемента колеса
|
||||
* @param {string} options.dataSourceId - ID элемента с данными членов команды
|
||||
* @param {Object} options.fallback - Данные руководителя для fallback
|
||||
* @param {string} options.fallback.avatar - URL аватара руководителя
|
||||
* @param {string} options.fallback.name - Имя руководителя
|
||||
* @param {string} options.fallback.role - Роль руководителя
|
||||
* @param {string} options.fallback.email - Email руководителя
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* initTeamWheel({
|
||||
* wheelId: 'teamWheel-123',
|
||||
* dataSourceId: 'teamData-123',
|
||||
* fallback: {
|
||||
* avatar: '/media/avatar.jpg',
|
||||
* name: 'Иванов Иван',
|
||||
* role: 'Руководитель отдела',
|
||||
* email: 'ivanov@example.com'
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
export function initTeamWheel(options) {
|
||||
const {
|
||||
wheelId,
|
||||
dataSourceId,
|
||||
fallback = {}
|
||||
} = options;
|
||||
|
||||
const wheel = document.getElementById(wheelId);
|
||||
if (!wheel) return;
|
||||
|
||||
const columns = wheel.querySelector('[data-columns]');
|
||||
const col1 = columns.querySelector('[data-col="1"]');
|
||||
const col2 = columns.querySelector('[data-col="2"]');
|
||||
const col3 = columns.querySelector('[data-col="3"]');
|
||||
const col4 = columns.querySelector('[data-col="4"]');
|
||||
|
||||
const focus = wheel.querySelector('[data-focus]');
|
||||
const fImg = wheel.querySelector('[data-focus-img]');
|
||||
const fFallback = wheel.querySelector('[data-focus-fallback]');
|
||||
|
||||
const wrapper = wheel.closest('.team-wrap');
|
||||
const hintBox = wrapper ? wrapper.querySelector('[data-hint]') : null;
|
||||
const details = wrapper ? wrapper.querySelector('[data-details]') : null;
|
||||
const dName = wrapper ? wrapper.querySelector('[data-details-name]') : null;
|
||||
const dRole = wrapper ? wrapper.querySelector('[data-details-role]') : null;
|
||||
const dMail = wrapper ? wrapper.querySelector('[data-details-email]') : null;
|
||||
|
||||
const arcProg = wheel.querySelector('.arc-prog');
|
||||
|
||||
// Источник данных
|
||||
const src = document.getElementById(dataSourceId);
|
||||
const members = Array.from(src ? src.children : []).map(n => ({
|
||||
id: Number(n.getAttribute('data-id') || '0'),
|
||||
name: n.getAttribute('data-name') || '',
|
||||
role: n.getAttribute('data-role') || '',
|
||||
email: n.getAttribute('data-email') || '',
|
||||
avatar: n.getAttribute('data-avatar') || '',
|
||||
active: n.getAttribute('data-active') === '1'
|
||||
}));
|
||||
|
||||
// Если никого нет — показываем руководителя
|
||||
if (!members.length) {
|
||||
showFallback(columns, focus, fImg, fFallback, hintBox, details, dName, dRole, dMail, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
// Сортировки для 4 колонок
|
||||
const byNameAsc = [...members].sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
||||
const byNameDesc = [...members].sort((a, b) => b.name.localeCompare(a.name, 'ru'));
|
||||
const byIdAsc = [...members].sort((a, b) => a.id - b.id);
|
||||
const byRoleAsc = [...members].sort((a, b) => {
|
||||
const ra = (a.role || '').toLowerCase();
|
||||
const rb = (b.role || '').toLowerCase();
|
||||
if (ra === rb) return a.name.localeCompare(b.name, 'ru');
|
||||
return ra < rb ? -1 : 1;
|
||||
});
|
||||
|
||||
fillCol(col1, byNameAsc);
|
||||
fillCol(col2, byNameDesc);
|
||||
fillCol(col3, byIdAsc);
|
||||
fillCol(col4, byRoleAsc);
|
||||
|
||||
// Инициализация автоскролла
|
||||
setupAutoScroll(wheel, columns, arcProg, focus, fImg, fFallback, hintBox, details, dName, dRole, dMail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает fallback (руководителя) когда команда пуста
|
||||
* @private
|
||||
*/
|
||||
function showFallback(columns, focus, fImg, fFallback, hintBox, details, dName, dRole, dMail, fallback) {
|
||||
columns.classList.add('d-none');
|
||||
focus.classList.remove('d-none');
|
||||
|
||||
const { avatar, name, role, email } = fallback;
|
||||
|
||||
if (avatar) {
|
||||
fImg.src = avatar;
|
||||
fImg.alt = name || 'Руководитель';
|
||||
fImg.style.display = 'block';
|
||||
fFallback.style.display = 'none';
|
||||
} else {
|
||||
fImg.removeAttribute('src');
|
||||
fImg.style.display = 'none';
|
||||
fFallback.style.display = 'block';
|
||||
}
|
||||
|
||||
if (hintBox) hintBox.classList.add('d-none');
|
||||
if (details) {
|
||||
details.classList.remove('d-none');
|
||||
if (dName) dName.textContent = name || 'Руководитель';
|
||||
if (dRole) dRole.textContent = role || 'Руководитель отдела';
|
||||
if (dMail) dMail.textContent = email || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает элемент для члена команды
|
||||
* @private
|
||||
*/
|
||||
function makeItem(member) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'wheel-item';
|
||||
btn.setAttribute('data-id', String(member.id));
|
||||
btn.setAttribute('data-name', member.name);
|
||||
btn.setAttribute('data-role', member.role || '');
|
||||
btn.setAttribute('data-email', member.email || '');
|
||||
btn.setAttribute('data-avatar', member.avatar || '');
|
||||
|
||||
const inner = document.createElement('span');
|
||||
inner.className = 'ava';
|
||||
|
||||
if (member.avatar) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.src = member.avatar;
|
||||
inner.appendChild(img);
|
||||
} else {
|
||||
const ico = document.createElement('i');
|
||||
ico.className = 'bi-person';
|
||||
inner.appendChild(ico);
|
||||
}
|
||||
|
||||
btn.appendChild(inner);
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Заполняет колонку элементами (с дублированием для бесконечного скролла)
|
||||
* @private
|
||||
*/
|
||||
function fillCol(root, arr) {
|
||||
root.innerHTML = '';
|
||||
// Дублируем для «бесконечной» прокрутки
|
||||
for (let k = 0; k < 2; k++) {
|
||||
arr.forEach(m => root.appendChild(makeItem(m)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает автоматический скролл с ручным управлением
|
||||
* @private
|
||||
*/
|
||||
function setupAutoScroll(wheel, columns, arcProg, focus, fImg, fFallback, hintBox, details, dName, dRole, dMail) {
|
||||
let vScroll = 0;
|
||||
let dir = 1; // 1 -> вниз, -1 -> вверх
|
||||
const speed = 0.45; // px/frame
|
||||
let hover = false;
|
||||
let focused = false;
|
||||
let maxY = Math.max(0, columns.scrollHeight - wheel.clientHeight);
|
||||
|
||||
function applyScroll() {
|
||||
columns.style.transform = `translateY(${-vScroll}px)`;
|
||||
if (maxY <= 0) {
|
||||
arcProg.style.strokeDashoffset = 1;
|
||||
} else {
|
||||
const p = vScroll / maxY; // 0..1
|
||||
arcProg.style.strokeDashoffset = (1 - p).toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
maxY = Math.max(0, columns.scrollHeight - wheel.clientHeight);
|
||||
vScroll = Math.min(vScroll, maxY);
|
||||
applyScroll();
|
||||
}
|
||||
|
||||
new ResizeObserver(recalc).observe(wheel);
|
||||
window.addEventListener('resize', recalc);
|
||||
|
||||
function loop() {
|
||||
if (!hover && !focused && maxY > 0) {
|
||||
vScroll += dir * speed;
|
||||
if (vScroll >= maxY) {
|
||||
vScroll = maxY;
|
||||
dir = -1; // на нижней границе едем вверх
|
||||
} else if (vScroll <= 0) {
|
||||
vScroll = 0;
|
||||
dir = 1; // цикл завершается только на верхней точке
|
||||
}
|
||||
applyScroll();
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
applyScroll();
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
// Hover — пауза автоскролла
|
||||
wheel.addEventListener('mouseenter', () => { hover = true; });
|
||||
wheel.addEventListener('mouseleave', () => { hover = false; });
|
||||
|
||||
// Ручной скролл колесом
|
||||
wheel.addEventListener('wheel', (e) => {
|
||||
if (!focused) {
|
||||
e.preventDefault();
|
||||
vScroll = Math.min(maxY, Math.max(0, vScroll + e.deltaY));
|
||||
applyScroll();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Ручной скролл тачем
|
||||
let tStartY = null;
|
||||
wheel.addEventListener('touchstart', (e) => {
|
||||
if (e.touches && e.touches.length) {
|
||||
tStartY = e.touches[0].clientY;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
wheel.addEventListener('touchmove', (e) => {
|
||||
if (tStartY != null && !focused) {
|
||||
const y = e.touches[0].clientY;
|
||||
const dy = tStartY - y;
|
||||
vScroll = Math.min(maxY, Math.max(0, vScroll + dy));
|
||||
tStartY = y;
|
||||
applyScroll();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
wheel.addEventListener('touchend', () => { tStartY = null; });
|
||||
|
||||
// Фокус: клик по мини-аве
|
||||
columns.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.wheel-item');
|
||||
if (!btn) return;
|
||||
|
||||
const ava = btn.getAttribute('data-avatar') || '';
|
||||
const name = btn.getAttribute('data-name') || 'Сотрудник';
|
||||
const role = btn.getAttribute('data-role') || '';
|
||||
const mail = btn.getAttribute('data-email') || '';
|
||||
|
||||
focused = true;
|
||||
columns.classList.add('d-none');
|
||||
focus.classList.remove('d-none');
|
||||
|
||||
if (ava) {
|
||||
fImg.src = ava;
|
||||
fImg.alt = name;
|
||||
fImg.style.display = 'block';
|
||||
fFallback.style.display = 'none';
|
||||
} else {
|
||||
fImg.removeAttribute('src');
|
||||
fImg.style.display = 'none';
|
||||
fFallback.style.display = 'block';
|
||||
}
|
||||
|
||||
if (hintBox) hintBox.classList.add('d-none');
|
||||
if (details) {
|
||||
details.classList.remove('d-none');
|
||||
if (dName) dName.textContent = name;
|
||||
if (dRole) dRole.textContent = role;
|
||||
if (dMail) dMail.textContent = mail;
|
||||
}
|
||||
});
|
||||
|
||||
// Выход из фокуса — клик по большому кругу
|
||||
focus.addEventListener('click', () => {
|
||||
focused = false;
|
||||
focus.classList.add('d-none');
|
||||
columns.classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
// Backward compatibility - expose to window
|
||||
if (typeof window !== 'undefined') {
|
||||
window.initTeamWheel = initTeamWheel;
|
||||
}
|
||||
@ -1,225 +1,17 @@
|
||||
{# ==== JS: круг с автоскроллом/ручным управлением и индикатором ==== #}
|
||||
<script>
|
||||
(function(){
|
||||
const wheel = document.getElementById('teamWheel-{{ dept.id }}');
|
||||
if (!wheel) return;
|
||||
<script type="module">
|
||||
import { initTeamWheel } from '{% static "js/components/teamWheel.js" %}';
|
||||
|
||||
const columns = wheel.querySelector('[data-columns]');
|
||||
const col1 = columns.querySelector('[data-col="1"]');
|
||||
const col2 = columns.querySelector('[data-col="2"]');
|
||||
const col3 = columns.querySelector('[data-col="3"]');
|
||||
const col4 = columns.querySelector('[data-col="4"]');
|
||||
|
||||
const focus = wheel.querySelector('[data-focus]');
|
||||
const fImg = wheel.querySelector('[data-focus-img]');
|
||||
const fFallback = wheel.querySelector('[data-focus-fallback]');
|
||||
|
||||
const wrapper = wheel.closest('.team-wrap');
|
||||
const hintBox = wrapper ? wrapper.querySelector('[data-hint]') : null;
|
||||
const details = wrapper ? wrapper.querySelector('[data-details]') : null;
|
||||
const dName = wrapper ? wrapper.querySelector('[data-details-name]') : null;
|
||||
const dRole = wrapper ? wrapper.querySelector('[data-details-role]') : null;
|
||||
const dMail = wrapper ? wrapper.querySelector('[data-details-email]') : null;
|
||||
|
||||
const arcProg = wheel.querySelector('.arc-prog');
|
||||
|
||||
// Источник данных
|
||||
const src = document.getElementById('teamData-{{ dept.id }}');
|
||||
const members = Array.from(src ? src.children : []).map(n => ({
|
||||
id: Number(n.getAttribute('data-id') || '0'),
|
||||
name: n.getAttribute('data-name') || '',
|
||||
role: n.getAttribute('data-role') || '',
|
||||
email: n.getAttribute('data-email') || '',
|
||||
avatar: n.getAttribute('data-avatar') || '',
|
||||
active: n.getAttribute('data-active') === '1'
|
||||
}));
|
||||
|
||||
// Если никого нет — показываем руководителя
|
||||
if (!members.length){
|
||||
columns.classList.add('d-none');
|
||||
focus.classList.remove('d-none');
|
||||
|
||||
const headAva = "{{ dept.head.avatar|default:'' }}";
|
||||
const headName= "{% if dept.head %}{{ dept.head.display_name|default:dept.head.last_name }}{% else %}Руководитель{% endif %}";
|
||||
const headRole= "Руководитель отдела";
|
||||
const headMail= "{% if dept.head and dept.head.email %}{{ dept.head.email }}{% endif %}";
|
||||
|
||||
if (headAva){
|
||||
fImg.src = headAva; fImg.alt = headName; fImg.style.display = 'block';
|
||||
fFallback.style.display = 'none';
|
||||
} else {
|
||||
fImg.removeAttribute('src'); fImg.style.display = 'none';
|
||||
fFallback.style.display = 'block';
|
||||
}
|
||||
if (hintBox) hintBox.classList.add('d-none');
|
||||
if (details){
|
||||
details.classList.remove('d-none');
|
||||
dName && (dName.textContent = headName);
|
||||
dRole && (dRole.textContent = headRole);
|
||||
dMail && (dMail.textContent = headMail);
|
||||
}
|
||||
return;
|
||||
initTeamWheel({
|
||||
wheelId: 'teamWheel-{{ dept.id }}',
|
||||
dataSourceId: 'teamData-{{ dept.id }}',
|
||||
fallback: {
|
||||
avatar: "{{ dept.head.avatar|default:'' }}",
|
||||
name: "{% if dept.head %}{{ dept.head.display_name|default:dept.head.last_name }}{% else %}Руководитель{% endif %}",
|
||||
role: "Руководитель отдела",
|
||||
email: "{% if dept.head and dept.head.email %}{{ dept.head.email }}{% endif %}"
|
||||
}
|
||||
|
||||
// Сортировки для 4 колонок: во всех — весь список
|
||||
const byNameAsc = [...members].sort((a,b)=>a.name.localeCompare(b.name,'ru'));
|
||||
const byNameDesc = [...members].sort((a,b)=>b.name.localeCompare(a.name,'ru'));
|
||||
const byIdAsc = [...members].sort((a,b)=>a.id-b.id);
|
||||
const byRoleAsc = [...members].sort((a,b)=>{
|
||||
const ra = (a.role||'').toLowerCase(), rb = (b.role||'').toLowerCase();
|
||||
if (ra === rb) return a.name.localeCompare(b.name,'ru');
|
||||
return ra < rb ? -1 : 1;
|
||||
});
|
||||
|
||||
function makeItem(m){
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'wheel-item';
|
||||
btn.setAttribute('data-id', String(m.id));
|
||||
btn.setAttribute('data-name', m.name);
|
||||
btn.setAttribute('data-role', m.role || '');
|
||||
btn.setAttribute('data-email', m.email || '');
|
||||
btn.setAttribute('data-avatar', m.avatar || '');
|
||||
|
||||
const inner = document.createElement('span');
|
||||
inner.className = 'ava';
|
||||
if (m.avatar){
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.src = m.avatar;
|
||||
inner.appendChild(img);
|
||||
} else {
|
||||
const ico = document.createElement('i');
|
||||
ico.className = 'bi-person';
|
||||
inner.appendChild(ico);
|
||||
}
|
||||
btn.appendChild(inner);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function fillCol(root, arr){
|
||||
root.innerHTML = '';
|
||||
// Дублируем для «бесконечной» прокрутки
|
||||
for (let k=0;k<2;k++){
|
||||
arr.forEach(m => root.appendChild(makeItem(m)));
|
||||
}
|
||||
}
|
||||
fillCol(col1, byNameAsc);
|
||||
fillCol(col2, byNameDesc);
|
||||
fillCol(col3, byIdAsc);
|
||||
fillCol(col4, byRoleAsc);
|
||||
|
||||
// Виртуальный скролл
|
||||
let vScroll = 0;
|
||||
let dir = 1; // 1 -> вниз, -1 -> вверх
|
||||
const speed = 0.45; // px/frame
|
||||
let hover = false;
|
||||
let focused = false;
|
||||
let maxY = Math.max(0, columns.scrollHeight - wheel.clientHeight);
|
||||
|
||||
function applyScroll(){
|
||||
columns.style.transform = `translateY(${-vScroll}px)`;
|
||||
if (maxY <= 0){
|
||||
arcProg.style.strokeDashoffset = 1;
|
||||
} else {
|
||||
const p = vScroll / maxY; // 0..1
|
||||
arcProg.style.strokeDashoffset = (1 - p).toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
function recalc(){
|
||||
maxY = Math.max(0, columns.scrollHeight - wheel.clientHeight);
|
||||
vScroll = Math.min(vScroll, maxY);
|
||||
applyScroll();
|
||||
}
|
||||
new ResizeObserver(recalc).observe(wheel);
|
||||
window.addEventListener('resize', recalc);
|
||||
|
||||
function loop(){
|
||||
if (!hover && !focused && maxY > 0){
|
||||
vScroll += dir * speed;
|
||||
if (vScroll >= maxY){
|
||||
vScroll = maxY;
|
||||
dir = -1; // на нижней границе едем вверх
|
||||
} else if (vScroll <= 0){
|
||||
vScroll = 0;
|
||||
dir = 1; // цикл завершается только на верхней точке
|
||||
}
|
||||
applyScroll();
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
applyScroll();
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
// Hover — пауза автоскролла, включаем ручной
|
||||
wheel.addEventListener('mouseenter', ()=>{ hover = true; });
|
||||
wheel.addEventListener('mouseleave', ()=>{ hover = false; });
|
||||
|
||||
// Ручной скролл колесом
|
||||
wheel.addEventListener('wheel', (e)=>{
|
||||
if (!focused){
|
||||
e.preventDefault();
|
||||
vScroll = Math.min(maxY, Math.max(0, vScroll + e.deltaY));
|
||||
applyScroll();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Ручной скролл тачем
|
||||
let tStartY = null;
|
||||
wheel.addEventListener('touchstart', (e)=>{
|
||||
if (e.touches && e.touches.length){
|
||||
tStartY = e.touches[0].clientY;
|
||||
}
|
||||
}, { passive: true });
|
||||
wheel.addEventListener('touchmove', (e)=>{
|
||||
if (tStartY != null && !focused){
|
||||
const y = e.touches[0].clientY;
|
||||
const dy = tStartY - y;
|
||||
vScroll = Math.min(maxY, Math.max(0, vScroll + dy));
|
||||
tStartY = y;
|
||||
applyScroll();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
wheel.addEventListener('touchend', ()=>{ tStartY = null; });
|
||||
|
||||
// Фокус: клик по мини-аве
|
||||
columns.addEventListener('click', (e)=>{
|
||||
const btn = e.target.closest('.wheel-item');
|
||||
if (!btn) return;
|
||||
const ava = btn.getAttribute('data-avatar') || '';
|
||||
const name= btn.getAttribute('data-name') || 'Сотрудник';
|
||||
const role= btn.getAttribute('data-role') || '';
|
||||
const mail= btn.getAttribute('data-email') || '';
|
||||
|
||||
focused = true;
|
||||
columns.classList.add('d-none');
|
||||
focus.classList.remove('d-none');
|
||||
|
||||
if (ava){
|
||||
fImg.src = ava; fImg.alt = name; fImg.style.display = 'block';
|
||||
fFallback.style.display = 'none';
|
||||
} else {
|
||||
fImg.removeAttribute('src'); fImg.style.display = 'none';
|
||||
fFallback.style.display = 'block';
|
||||
}
|
||||
hintBox && hintBox.classList.add('d-none');
|
||||
if (details){
|
||||
details.classList.remove('d-none');
|
||||
dName && (dName.textContent = name);
|
||||
dRole && (dRole.textContent = role);
|
||||
dMail && (dMail.textContent = mail);
|
||||
}
|
||||
});
|
||||
|
||||
// Выход из фокуса — клик по большому кругу
|
||||
focus.addEventListener('click', ()=>{
|
||||
focused = false;
|
||||
focus.classList.add('d-none');
|
||||
columns.classList.remove('d-none');
|
||||
});
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
{# ===== Переключатель панели настроек (скролл к блоку при открытии) ===== #}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user