refactor: extract JavaScript utilities to separate modules (Phase 2)

- Created reusable utility modules:
  * static/js/utils/stringUtils.js - string operations (esc, norm, escAttr, truncate, getInitials)
  * static/js/utils/timing.js - timing utilities (debounce, throttle, delay, poll, once)
  * static/js/utils/scroll.js - scroll helpers (smoothScrollTo, scrollToTop, isElementInViewport, etc.)

- Updated static/js/utils/index.js:
  * Exports all utilities from a single entry point
  * Provides centralized access to all utility functions

- Modified base.html:
  * Removed inline esc() and norm() function definitions
  * Added ES6 module imports for utilities
  * Exported utilities to window for backward compatibility
  * All existing code continues to work without changes

Benefits:
- Eliminates code duplication (esc/norm found in 9 templates)
- Centralizes utility functions for easier maintenance
- Enables future improvements (testing, optimization)
- Maintains backward compatibility with existing templates

Tests: All 12 static file tests passing ✅
This commit is contained in:
admin i 2025-10-28 08:37:02 +03:00
parent 303fd54d17
commit c3fd4916d1
5 changed files with 474 additions and 14 deletions

View File

@ -13,14 +13,33 @@
* import { esc } from '{% static "js/utils/stringUtils.js" %}';
*/
// Утилиты работы со строками (будут созданы в Фазе 2)
// export { esc, norm } from './stringUtils.js';
// Утилиты работы со строками
export {
esc,
norm,
escAttr,
truncate,
getInitials
} from './stringUtils.js';
// Утилиты для работы со временем (будут созданы в Фазе 2)
// export { debounce, throttle } from './timing.js';
// Утилиты для работы со временем
export {
debounce,
throttle,
delay,
poll,
once
} from './timing.js';
// Утилиты прокрутки (будут созданы в Фазе 2)
// export { smoothScrollTo } from './scroll.js';
// Утилиты прокрутки
export {
smoothScrollTo,
scrollToTop,
scrollToBottom,
isElementInViewport,
getScrollPosition,
onScrollThreshold
} from './scroll.js';
// Placeholder export для валидности модуля
// Версия утилит
export const UTILS_VERSION = '1.0.0';

View File

@ -0,0 +1,164 @@
/**
* Scroll Utilities
*
* Утилиты для работы с прокруткой страницы
*
* Использование:
* import { smoothScrollTo, scrollToTop } from '{% static "js/utils/scroll.js" %}';
*/
/**
* Плавная прокрутка к элементу с учётом фиксированного navbar
*
* @param {HTMLElement|string} target - элемент или селектор для прокрутки
* @param {Object} options - опции прокрутки
* @param {number} options.offset - дополнительный отступ сверху (по умолчанию 20)
* @param {string} options.behavior - тип прокрутки ('smooth' или 'auto')
* @returns {void}
*
* @example
* // Прокрутка к элементу
* smoothScrollTo('#section2');
*
* // С дополнительным отступом
* smoothScrollTo(document.querySelector('.target'), { offset: 50 });
*/
export function smoothScrollTo(target, options = {}) {
const element = typeof target === 'string'
? document.querySelector(target)
: target;
if (!element) {
console.warn('smoothScrollTo: element not found', target);
return;
}
const {
offset = 20,
behavior = 'smooth'
} = options;
// Высота фиксированного navbar (если есть)
const navbar = document.querySelector('.app-navbar, .navbar-fixed-top, .fixed-top');
const navbarHeight = navbar?.offsetHeight || 0;
// Позиция элемента относительно верха документа
const elementTop = element.getBoundingClientRect().top + window.pageYOffset;
// Финальная позиция с учётом navbar и offset
const scrollToPosition = elementTop - navbarHeight - offset;
window.scrollTo({
top: scrollToPosition,
behavior: behavior
});
}
/**
* Прокрутка в начало страницы
*
* @param {boolean} smooth - плавная прокрутка (по умолчанию true)
* @returns {void}
*
* @example
* scrollToTop(); // плавная прокрутка вверх
* scrollToTop(false); // мгновенная прокрутка
*/
export function scrollToTop(smooth = true) {
window.scrollTo({
top: 0,
behavior: smooth ? 'smooth' : 'auto'
});
}
/**
* Прокрутка в конец страницы
*
* @param {boolean} smooth - плавная прокрутка (по умолчанию true)
* @returns {void}
*
* @example
* scrollToBottom(); // плавная прокрутка вниз
*/
export function scrollToBottom(smooth = true) {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
});
}
/**
* Проверка видимости элемента в viewport
*
* @param {HTMLElement} element - элемент для проверки
* @param {number} threshold - процент видимости (0-1)
* @returns {boolean} - виден ли элемент
*
* @example
* if (isElementInViewport(element, 0.5)) {
* console.log('Элемент виден более чем на 50%');
* }
*/
export function isElementInViewport(element, threshold = 0) {
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
const vertInView = (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0);
const horInView = (rect.left <= windowWidth) && ((rect.left + rect.width) >= 0);
if (threshold === 0) {
return vertInView && horInView;
}
// Процент видимости
const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0);
const visibleWidth = Math.min(rect.right, windowWidth) - Math.max(rect.left, 0);
const visibleArea = visibleHeight * visibleWidth;
const totalArea = rect.height * rect.width;
return (visibleArea / totalArea) >= threshold;
}
/**
* Получение текущей позиции скролла
*
* @returns {Object} - объект с координатами { x, y }
*
* @example
* const { x, y } = getScrollPosition();
* console.log('Scrolled:', x, y);
*/
export function getScrollPosition() {
return {
x: window.pageXOffset || document.documentElement.scrollLeft,
y: window.pageYOffset || document.documentElement.scrollTop
};
}
/**
* Скрытие/показ элемента при прокрутке (например, кнопки "наверх")
*
* @param {HTMLElement} element - элемент для показа/скрытия
* @param {number} threshold - порог прокрутки в пикселях
* @param {Function} callback - функция обратного вызова
* @returns {Function} - функция для отписки от события
*
* @example
* const btn = document.querySelector('#backToTop');
* const unsubscribe = onScrollThreshold(btn, 300, (visible) => {
* btn.style.display = visible ? 'block' : 'none';
* });
*/
export function onScrollThreshold(element, threshold, callback) {
const handleScroll = () => {
const scrollY = window.pageYOffset || document.documentElement.scrollTop;
callback(scrollY > threshold);
};
window.addEventListener('scroll', handleScroll);
handleScroll(); // вызвать сразу
// Возвращаем функцию для отписки
return () => window.removeEventListener('scroll', handleScroll);
}

View File

@ -0,0 +1,107 @@
/**
* String Utilities
*
* Утилиты для работы со строками: экранирование, нормализация и т.д.
*
* Использование:
* import { esc, norm } from '{% static "js/utils/stringUtils.js" %}';
*/
/**
* Экранирование HTML символов для безопасного отображения
* Защищает от XSS атак при выводе пользовательского контента
*
* @param {string|number|null|undefined} s - строка для экранирования
* @returns {string} - экранированная строка
*
* @example
* esc('<script>alert("XSS")</script>')
* // => '&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'
*
* esc("O'Reilly & Sons")
* // => 'O&#39;Reilly &amp; Sons'
*/
export function esc(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Нормализация строки для поиска и сравнения
* Приводит к lowercase, заменяет множественные пробелы на один, убирает пробелы с краёв
*
* @param {string|null|undefined} s - строка для нормализации
* @returns {string} - нормализованная строка
*
* @example
* norm(' Иван Петров ')
* // => 'иван петров'
*
* norm('МОСКВА')
* // => 'москва'
*/
export function norm(s) {
return (s || '').toString().toLowerCase().replace(/\s+/g, ' ').trim();
}
/**
* Экранирование для использования в атрибутах HTML
* Более строгое, чем esc() - заменяет больше символов
*
* @param {string|null|undefined} s - строка для экранирования
* @returns {string} - экранированная строка для атрибута
*
* @example
* escAttr('value="test"')
* // => 'value=&quot;test&quot;'
*/
export function escAttr(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}
/**
* Усечение строки до указанной длины с добавлением многоточия
*
* @param {string} s - строка для усечения
* @param {number} maxLen - максимальная длина (включая многоточие)
* @returns {string} - усечённая строка
*
* @example
* truncate('Очень длинная строка текста', 15)
* // => 'Очень длинн...'
*/
export function truncate(s, maxLen) {
const str = String(s || '');
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 3) + '...';
}
/**
* Извлечение инициалов из ФИО
*
* @param {string} fullName - полное имя
* @returns {string} - инициалы
*
* @example
* getInitials('Иванов Иван Петрович')
* // => 'ИИ'
*
* getInitials('John Doe')
* // => 'JD'
*/
export function getInitials(fullName) {
const parts = String(fullName || '').trim().split(/\s+/);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
return (parts[0].charAt(0) + parts[1].charAt(0)).toUpperCase();
}

View File

@ -0,0 +1,153 @@
/**
* Timing Utilities
*
* Утилиты для управления временем выполнения функций: debounce, throttle и т.д.
*
* Использование:
* import { debounce, throttle } from '{% static "js/utils/timing.js" %}';
*/
/**
* Debounce - откладывает выполнение функции до окончания "тишины"
* Используется для оптимизации обработчиков событий (поиск, resize, scroll и т.д.)
*
* @param {Function} fn - функция для выполнения
* @param {number} ms - задержка в миллисекундах (по умолчанию 300)
* @returns {Function} - обёрнутая функция
*
* @example
* const handleSearch = debounce((query) => {
* console.log('Searching for:', query);
* }, 300);
*
* // При быстром наборе будет вызван только последний
* handleSearch('a'); // не выполнится
* handleSearch('ab'); // не выполнится
* handleSearch('abc'); // выполнится через 300мс после последнего вызова
*/
export function debounce(fn, ms = 300) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), ms);
};
}
/**
* Throttle - ограничивает частоту выполнения функции
* Гарантирует выполнение не чаще чем раз в указанный интервал
*
* @param {Function} fn - функция для выполнения
* @param {number} ms - минимальный интервал между вызовами (по умолчанию 300)
* @returns {Function} - обёрнутая функция
*
* @example
* const handleScroll = throttle(() => {
* console.log('Scroll position:', window.scrollY);
* }, 100);
*
* // При скролле будет вызываться максимум раз в 100мс
* window.addEventListener('scroll', handleScroll);
*/
export function throttle(fn, ms = 300) {
let inThrottle;
let lastFn;
let lastTime;
return function(...args) {
const context = this;
if (!inThrottle) {
fn.apply(context, args);
lastTime = Date.now();
inThrottle = true;
} else {
clearTimeout(lastFn);
lastFn = setTimeout(() => {
if (Date.now() - lastTime >= ms) {
fn.apply(context, args);
lastTime = Date.now();
}
}, Math.max(ms - (Date.now() - lastTime), 0));
}
};
}
/**
* Delay - создаёт Promise, который разрешается через указанное время
* Полезно для async/await паттернов
*
* @param {number} ms - задержка в миллисекундах
* @returns {Promise} - Promise, который разрешится через указанное время
*
* @example
* async function loadData() {
* console.log('Loading...');
* await delay(1000);
* console.log('Loaded!');
* }
*/
export function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Polling - периодический вызов функции до выполнения условия
*
* @param {Function} fn - функция для выполнения (должна возвращать boolean)
* @param {number} interval - интервал между вызовами в мс
* @param {number} timeout - максимальное время ожидания в мс
* @returns {Promise<boolean>} - Promise, который разрешится когда fn вернёт true
*
* @example
* // Ждём пока элемент появится в DOM
* await poll(
* () => document.querySelector('#myElement') !== null,
* 100, // проверяем каждые 100мс
* 5000 // максимум 5 секунд
* );
*/
export function poll(fn, interval = 100, timeout = 5000) {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const checkCondition = () => {
if (fn()) {
resolve(true);
} else if (Date.now() - startTime >= timeout) {
reject(new Error('Polling timeout'));
} else {
setTimeout(checkCondition, interval);
}
};
checkCondition();
});
}
/**
* Once - гарантирует однократное выполнение функции
*
* @param {Function} fn - функция для выполнения
* @returns {Function} - обёрнутая функция
*
* @example
* const initialize = once(() => {
* console.log('Initialized!');
* });
*
* initialize(); // выведет "Initialized!"
* initialize(); // ничего не произойдёт
*/
export function once(fn) {
let called = false;
let result;
return function(...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}

View File

@ -481,6 +481,26 @@
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.8/locales-all.global.min.js" defer></script>
{# Импорт утилит из модулей #}
<script type="module">
import { esc, norm, escAttr, truncate, getInitials } from '{% static "js/utils/stringUtils.js" %}';
import { debounce, throttle, delay } from '{% static "js/utils/timing.js" %}';
import { smoothScrollTo, scrollToTop, isElementInViewport } from '{% static "js/utils/scroll.js" %}';
// Экспортируем в window для обратной совместимости с существующим кодом
window.esc = esc;
window.norm = norm;
window.escAttr = escAttr;
window.truncate = truncate;
window.getInitials = getInitials;
window.debounce = debounce;
window.throttle = throttle;
window.delay = delay;
window.smoothScrollTo = smoothScrollTo;
window.scrollToTop = scrollToTop;
window.isElementInViewport = isElementInViewport;
</script>
<script>
// реальная высота navbar -> CSS var
document.addEventListener('DOMContentLoaded', () => {
@ -794,12 +814,9 @@
return `${z2(d.getDate())}.${z2(d.getMonth()+1)}.${d.getFullYear()}`;
}
function esc(s){
return String(s || '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;')
.replace(/'/g,'&#39;');
}
// HTML экранирование и утилиты импортированы из модулей
// См. блок импорта утилит ниже
function avatarHTML(url){
return url
? `<span class="mini-ava border"><img src="${url}" alt="" loading="lazy"></span>`
@ -937,7 +954,7 @@
private: document.querySelector('.chat-section[data-sec="private"]')
};
let active = 'all';
const norm = s => (s || '').toLowerCase().replace(/\s+/g,' ').trim();
// norm() импортирован из утилит, доступен через window.norm
function apply(){
const q = norm(search?.value || '');