diff --git a/backend/static/js/utils/index.js b/backend/static/js/utils/index.js index 7e888336..9e74239d 100644 --- a/backend/static/js/utils/index.js +++ b/backend/static/js/utils/index.js @@ -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'; diff --git a/backend/static/js/utils/scroll.js b/backend/static/js/utils/scroll.js new file mode 100644 index 00000000..59c50ce7 --- /dev/null +++ b/backend/static/js/utils/scroll.js @@ -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); +} diff --git a/backend/static/js/utils/stringUtils.js b/backend/static/js/utils/stringUtils.js new file mode 100644 index 00000000..718f2fd2 --- /dev/null +++ b/backend/static/js/utils/stringUtils.js @@ -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>' + * + * esc("O'Reilly & Sons") + * // => 'O'Reilly & Sons' + */ +export function esc(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Нормализация строки для поиска и сравнения + * Приводит к 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="test"' + */ +export function escAttr(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); +} + +/** + * Усечение строки до указанной длины с добавлением многоточия + * + * @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(); +} diff --git a/backend/static/js/utils/timing.js b/backend/static/js/utils/timing.js new file mode 100644 index 00000000..0fd629d8 --- /dev/null +++ b/backend/static/js/utils/timing.js @@ -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} - 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; + }; +} diff --git a/backend/templates/base.html b/backend/templates/base.html index 8c98f5ad..9d8e619c 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -481,6 +481,26 @@ + {# Импорт утилит из модулей #} + +