first commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# Disable directory browsing
|
||||
Options -Indexes
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Rewrite engine
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
# Turning on the rewrite engine is necessary for the following rules and features.
|
||||
# FollowSymLinks must be enabled for this to work.
|
||||
<IfModule mod_rewrite.c>
|
||||
Options +FollowSymlinks
|
||||
RewriteEngine On
|
||||
|
||||
# If you installed CodeIgniter in a subfolder, you will need to
|
||||
# change the following line to match the subfolder you need.
|
||||
# http://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewritebase
|
||||
# RewriteBase /
|
||||
|
||||
# Redirect Trailing Slashes...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Rewrite "www.example.com -> example.com"
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
|
||||
RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Checks to see if the user is attempting to access a valid file,
|
||||
# such as an image or css document, if this isn't true it sends the
|
||||
# request to the front controller, index.php
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA]
|
||||
|
||||
# Ensure Authorization header is passed along
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
</IfModule>
|
||||
|
||||
<IfModule !mod_rewrite.c>
|
||||
# If we don't have mod_rewrite installed, all 404's
|
||||
# can be sent to index.php, and everything works as normal.
|
||||
ErrorDocument 404 index.php
|
||||
</IfModule>
|
||||
|
||||
# Disable server signature start
|
||||
ServerSignature Off
|
||||
# Disable server signature end
|
||||
Vendored
+9
File diff suppressed because one or more lines are too long
@@ -0,0 +1,111 @@
|
||||
/* Основные стили для сайдбара */
|
||||
#wrapper {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#sidebar-wrapper {
|
||||
min-height: 100vh;
|
||||
margin-left: 0;
|
||||
width: 250px;
|
||||
transition: margin-left 0.3s ease, width 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#page-content-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
/* Стили для свернутого сайдбара */
|
||||
body.sb-sidenav-toggled #sidebar-wrapper {
|
||||
margin-left: -250px;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
body.sb-sidenav-toggled #page-content-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Стили для активных пунктов меню */
|
||||
.sidebar-link {
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background-color: rgba(13, 110, 253, 0.05);
|
||||
border-left-color: rgba(13, 110, 253, 0.3);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
border-left-color: var(--bs-primary, #0d6efd);
|
||||
color: var(--bs-primary, #0d6efd) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-link.active i {
|
||||
color: var(--bs-primary, #0d6efd) !important;
|
||||
}
|
||||
|
||||
.sidebar-link.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.sidebar-link.disabled:hover {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
/* Адаптивность для мобильных */
|
||||
@media (max-width: 768px) {
|
||||
#sidebar-wrapper {
|
||||
margin-left: -250px;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
#page-content-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
body.sb-sidenav-toggled #sidebar-wrapper {
|
||||
margin-left: 0;
|
||||
width: 250px;
|
||||
z-index: 1050;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body.sb-sidenav-toggled #page-content-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
body.sb-sidenav-toggled::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1049;
|
||||
}
|
||||
}
|
||||
|
||||
/* Иконка бургера - анимация */
|
||||
#sidebarToggle .fa-bars {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
body.sb-sidenav-toggled #sidebarToggle .fa-bars {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Стили для заголовка сайдбара */
|
||||
.sidebar-heading {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* DataTable - Универсальные стили для интерактивных таблиц
|
||||
*
|
||||
* Подключение: <link rel="stylesheet" href="/css/components/data-table.css">
|
||||
*/
|
||||
|
||||
/* Основной контейнер таблицы */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Интерактивные заголовки */
|
||||
.data-table thead th {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
padding: 0.5rem 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.data-table .header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Текст заголовка */
|
||||
.data-table .header-text {
|
||||
cursor: pointer;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
transition: color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table .header-text:hover {
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
|
||||
/* Иконки в заголовке */
|
||||
.data-table .search-trigger,
|
||||
.data-table .sort-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.data-table .search-trigger {
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease, color 0.2s ease;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table .search-trigger:hover {
|
||||
opacity: 1;
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
|
||||
.data-table .sort-icon {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table .sort-icon:hover {
|
||||
color: var(--bs-primary, #0d6efd) !important;
|
||||
}
|
||||
|
||||
.data-table .sort-icon.active {
|
||||
color: var(--bs-primary, #0d6efd) !important;
|
||||
}
|
||||
|
||||
/* Поле ввода - абсолютное позиционирование */
|
||||
.data-table .header-search-input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb, 13, 110, 253), 0.25);
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-table .header-search-input.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Скрываем элементы заголовка при активном поиске */
|
||||
.data-table .header-search-input.visible ~ .search-trigger,
|
||||
.data-table .header-search-input.visible ~ .header-text,
|
||||
.data-table .header-search-input.visible ~ .sort-icon {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Пагинация - исправленная структура */
|
||||
.data-table .pagination-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--bs-border-color, #dee2e6);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background-color: #fff;
|
||||
border-bottom-left-radius: 0.375rem;
|
||||
border-bottom-right-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Информация слева */
|
||||
.data-table .pagination-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Навигация по центру */
|
||||
.data-table .pagination-wrapper > nav.pagination {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.data-table .pagination-wrapper > nav.pagination .pagination {
|
||||
margin-bottom: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Селектор справа */
|
||||
.data-table .per-page-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Компактный селектор */
|
||||
.data-table .per-page-selector .per-page-select,
|
||||
.data-table .per-page-selector select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 16px 12px;
|
||||
padding-right: 2rem !important;
|
||||
width: 80px !important;
|
||||
font-size: 0.875rem !important;
|
||||
padding: 0.25rem 0.5rem !important;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Убираем отступы у ячейки футера */
|
||||
.data-table tfoot td {
|
||||
padding: 0 !important;
|
||||
border-top: 1px solid var(--bs-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
/* Убираем границы у строки футера */
|
||||
.data-table tfoot tr {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Кнопки пагинации */
|
||||
.data-table .page-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
.data-table .page-link:hover:not(.disabled):not(.active) {
|
||||
background-color: var(--bs-primary-bg-subtle, #e9ecef);
|
||||
}
|
||||
|
||||
.data-table .page-link.active {
|
||||
background-color: var(--bs-primary, #0d6efd);
|
||||
color: #fff;
|
||||
border-color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
|
||||
.data-table .page-link.disabled {
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Контейнер селектора не должен растягиваться */
|
||||
.data-table .per-page-selector {
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.data-table .per-page-selector label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Стили для ячеек с действиями */
|
||||
.data-table .actions-cell {
|
||||
white-space: nowrap;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.data-table .action-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Hover эффекты для строк */
|
||||
.data-table tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background-color: var(--bs-table-hover-bg, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
/* Статус активной сортировки в заголовке */
|
||||
.data-table th.sorted-asc .sort-icon::before {
|
||||
content: '\f160';
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.data-table th.sorted-desc .sort-icon::before {
|
||||
content: '\f161';
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* Анимация появления */
|
||||
@keyframes data-table-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.data-table .header-search-input {
|
||||
animation: data-table-fade-in 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Стили для загрузки */
|
||||
.data-table .loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.data-table .loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Пустое состояние */
|
||||
.data-table .empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
}
|
||||
|
||||
.data-table .empty-state i {
|
||||
font-size: 3rem;
|
||||
opacity: 0.3;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.data-table .empty-state h5 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.data-table .empty-state p {
|
||||
margin-bottom: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Адаптивность для мобильных */
|
||||
@media (max-width: 768px) {
|
||||
.data-table .header-content {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.data-table .header-text {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table .search-trigger,
|
||||
.data-table .sort-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table .pagination-wrapper {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.data-table .pagination-info,
|
||||
.data-table .per-page-selector {
|
||||
flex: 1 1 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.data-table .per-page-selector {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Исправляем Bootstrap стили для пагинации внутри таблицы */
|
||||
.table .pagination {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Скрипт тоггла сайдбара с улучшенной обработкой
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const sidebarToggle = document.getElementById('sidebarToggle');
|
||||
const sidebarWrapper = document.getElementById('sidebar-wrapper');
|
||||
const body = document.body;
|
||||
|
||||
if (sidebarToggle) {
|
||||
// Обработчик клика
|
||||
sidebarToggle.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Переключаем класс
|
||||
body.classList.toggle('sb-sidenav-toggled');
|
||||
|
||||
// Сохраняем состояние в localStorage
|
||||
const isToggled = body.classList.contains('sb-sidenav-toggled');
|
||||
localStorage.setItem('sidebar-toggled', isToggled);
|
||||
});
|
||||
|
||||
// Закрытие сайдбара при клике вне его (для мобильных)
|
||||
document.addEventListener('click', function(event) {
|
||||
if (window.innerWidth <= 768 &&
|
||||
body.classList.contains('sb-sidenav-toggled') &&
|
||||
!sidebarWrapper.contains(event.target) &&
|
||||
event.target !== sidebarToggle &&
|
||||
!sidebarToggle.contains(event.target)) {
|
||||
body.classList.remove('sb-sidenav-toggled');
|
||||
localStorage.setItem('sidebar-toggled', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Восстановление состояния из localStorage
|
||||
const sidebarToggled = localStorage.getItem('sidebar-toggled');
|
||||
if (sidebarToggled === 'true' && window.innerWidth > 768) {
|
||||
body.classList.add('sb-sidenav-toggled');
|
||||
}
|
||||
}
|
||||
|
||||
// Адаптивное поведение при изменении размера окна
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth > 768) {
|
||||
// На десктопе всегда показываем сайдбар
|
||||
body.classList.remove('sb-sidenav-toggled');
|
||||
}
|
||||
});
|
||||
|
||||
// Подсветка активного пункта меню при загрузке
|
||||
highlightActiveMenuItem();
|
||||
|
||||
function highlightActiveMenuItem() {
|
||||
const currentPath = window.location.pathname;
|
||||
const menuItems = document.querySelectorAll('.sidebar-link');
|
||||
|
||||
menuItems.forEach(item => {
|
||||
const href = item.getAttribute('href');
|
||||
if (href && href !== '#' && currentPath.includes(href.replace(baseUrl, ''))) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Базовая функция для получения base URL
|
||||
const baseUrl = '{{ base_url("/") }}'.replace(/\/+$/, '');
|
||||
});
|
||||
+7
File diff suppressed because one or more lines are too long
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* DataTable - Универсальный модуль для интерактивных таблиц
|
||||
*
|
||||
* Функциональность:
|
||||
* - AJAX-загрузка данных
|
||||
* - Сортировка по столбцам
|
||||
* - Поиск по столбцам (click-to-search)
|
||||
* - Пагинация
|
||||
* - Состояние URL (back/forward навигация)
|
||||
* - Сохранение состояния поиска между запросами
|
||||
*/
|
||||
class DataTable {
|
||||
/**
|
||||
* @param {string} containerId - ID контейнера таблицы
|
||||
* @param {Object} options - Конфигурация таблицы
|
||||
*/
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
url: options.url || '/api/table',
|
||||
perPage: options.perPage || 10,
|
||||
debounceTime: options.debounceTime || 300,
|
||||
preserveSearchOnSort: options.preserveSearchOnSort !== false,
|
||||
...options
|
||||
};
|
||||
|
||||
// Состояние таблицы
|
||||
this.state = {
|
||||
page: 1,
|
||||
perPage: this.options.perPage,
|
||||
sort: '',
|
||||
order: 'asc',
|
||||
filters: {}
|
||||
};
|
||||
|
||||
this.searchTimeout = null;
|
||||
|
||||
// Инициализация с задержкой для уверенности что DOM готов
|
||||
this.initWithDOM();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация с проверкой доступности DOM
|
||||
*/
|
||||
initWithDOM() {
|
||||
// Пробуем найти контейнер
|
||||
this.container = document.getElementById(this.containerId);
|
||||
|
||||
if (!this.container) {
|
||||
// Контейнер не найден - пробуем через setTimeout
|
||||
setTimeout(() => {
|
||||
this.container = document.getElementById(this.containerId);
|
||||
if (this.container) {
|
||||
this.init();
|
||||
} else {
|
||||
// Контейнер всё ещё не найден, это нормально для AJAX-обновлений
|
||||
// Не показываем ошибку в консоли
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация таблицы
|
||||
*/
|
||||
init() {
|
||||
// Проверяем, не инициализирован ли уже этот экземпляр
|
||||
if (this.container.dataset.datatableInitialized) {
|
||||
return;
|
||||
}
|
||||
this.container.dataset.datatableInitialized = 'true';
|
||||
|
||||
// Проверяем, есть ли уже данные в таблице (рендер на сервере)
|
||||
const tbody = this.container.querySelector('tbody');
|
||||
const existingRows = tbody ? tbody.querySelectorAll('tr') : [];
|
||||
|
||||
// Если есть строки в tbody и это не строка загрузки - данные уже есть
|
||||
if (existingRows.length > 0) {
|
||||
const hasDataRow = Array.from(existingRows).some(tr => !tr.classList.contains('loading'));
|
||||
if (hasDataRow) {
|
||||
// Данные уже есть на странице - не делаем AJAX запрос
|
||||
this.bindEvents();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Если tbody пустой или содержит только строку загрузки - делаем AJAX запрос
|
||||
this.bindEvents();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Привязка событий
|
||||
*/
|
||||
bindEvents() {
|
||||
// Делегирование кликов на уровне контейнера
|
||||
this.container.addEventListener('click', (e) => this.handleClick(e));
|
||||
|
||||
// Делегирование ввода в поля поиска
|
||||
this.container.addEventListener('input', (e) => this.handleInput(e));
|
||||
|
||||
// Обработка Enter в полях поиска
|
||||
this.container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && e.target.matches('[data-search-input]')) {
|
||||
e.preventDefault();
|
||||
e.target.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Обработка изменения количества записей на странице
|
||||
this.container.addEventListener('change', (e) => {
|
||||
if (e.target.matches('[id^="per-page-select-"]')) {
|
||||
const value = e.target.value;
|
||||
this.setPerPage(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка кликов
|
||||
*/
|
||||
handleClick(e) {
|
||||
// Клик по иконке сортировки
|
||||
const sortIcon = e.target.closest('[data-sort]');
|
||||
if (sortIcon) {
|
||||
e.stopPropagation();
|
||||
const column = sortIcon.dataset.sort;
|
||||
this.toggleSort(column);
|
||||
return;
|
||||
}
|
||||
|
||||
// Клик по иконке поиска
|
||||
const searchTrigger = e.target.closest('[data-search-trigger]');
|
||||
if (searchTrigger) {
|
||||
e.stopPropagation();
|
||||
const th = searchTrigger.closest('th');
|
||||
this.openSearch(th);
|
||||
return;
|
||||
}
|
||||
|
||||
// Клик по тексту заголовка
|
||||
const headerText = e.target.closest('[data-header-text]');
|
||||
if (headerText) {
|
||||
e.stopPropagation();
|
||||
const th = headerText.closest('th');
|
||||
this.openSearch(th);
|
||||
return;
|
||||
}
|
||||
|
||||
// Клик по ссылке пагинации
|
||||
const pageLink = e.target.closest('[data-page]');
|
||||
if (pageLink && !e.target.closest('.disabled') && !e.target.closest('.active')) {
|
||||
e.preventDefault();
|
||||
const page = parseInt(pageLink.dataset.page);
|
||||
if (page > 0) {
|
||||
this.state.page = page;
|
||||
this.loadData();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Клик по кнопке "предыдущая/следующая" страница
|
||||
const navLink = e.target.closest('[data-nav-page]');
|
||||
if (navLink && !e.target.closest('.disabled')) {
|
||||
e.preventDefault();
|
||||
const direction = navLink.dataset.navPage;
|
||||
this.state.page = direction === 'prev'
|
||||
? Math.max(1, this.state.page - 1)
|
||||
: this.state.page + 1;
|
||||
this.loadData();
|
||||
return;
|
||||
}
|
||||
|
||||
// Клик вне заголовков таблицы - закрываем все поисковые поля
|
||||
if (!e.target.closest('thead')) {
|
||||
this.closeAllSearches();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка ввода в поля поиска
|
||||
*/
|
||||
handleInput(e) {
|
||||
if (e.target.matches('[data-search-input]')) {
|
||||
const column = e.target.dataset.searchInput;
|
||||
this.debouncedSearch(column, e.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced поиск
|
||||
*/
|
||||
debouncedSearch(column, value) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.state.filters[column] = value;
|
||||
this.state.page = 1;
|
||||
this.loadData();
|
||||
}, this.options.debounceTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключение сортировки
|
||||
*/
|
||||
toggleSort(column) {
|
||||
if (this.state.sort === column) {
|
||||
this.state.order = this.state.order === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.state.sort = column;
|
||||
this.state.order = 'desc';
|
||||
}
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Открытие поля поиска
|
||||
*/
|
||||
openSearch(th) {
|
||||
const headerText = th.querySelector('[data-header-text]');
|
||||
const searchInput = th.querySelector('[data-search-input]');
|
||||
|
||||
if (headerText && searchInput) {
|
||||
headerText.style.display = 'none';
|
||||
searchInput.style.display = 'inline';
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрытие всех полей поиска
|
||||
*/
|
||||
closeAllSearches() {
|
||||
const inputs = this.container.querySelectorAll('[data-search-input]');
|
||||
inputs.forEach(input => {
|
||||
const th = input.closest('th');
|
||||
const headerText = th.querySelector('[data-header-text]');
|
||||
|
||||
if (headerText) {
|
||||
if (input.value.trim()) {
|
||||
// Если есть значение - показываем заголовок, скрываем input
|
||||
input.style.display = 'none';
|
||||
headerText.style.display = 'inline';
|
||||
} else {
|
||||
// Если пусто - также скрываем input и показываем заголовок
|
||||
input.style.display = 'none';
|
||||
headerText.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение CSRF токена из cookie CodeIgniter 4
|
||||
*/
|
||||
getCsrfToken() {
|
||||
// Сначала пробуем из data-атрибутов контейнера (если заданы)
|
||||
if (this.container.dataset.csrfToken) {
|
||||
return this.container.dataset.csrfToken;
|
||||
}
|
||||
|
||||
// Пробуем из скрытого input внутри контейнера
|
||||
const csrfInput = this.container.querySelector('input[name*="csrf"]');
|
||||
if (csrfInput) {
|
||||
return csrfInput.value;
|
||||
}
|
||||
|
||||
// Пробуем из скрытого input на всей странице
|
||||
const globalCsrfInput = document.querySelector('input[name*="csrf"]');
|
||||
if (globalCsrfInput) {
|
||||
return globalCsrfInput.value;
|
||||
}
|
||||
|
||||
// Пробуем из cookie - ищем по паттерну
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
// Ищем cookie, содержащий 'csrf' в имени
|
||||
if (name.toLowerCase().includes('csrf')) {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('CSRF token not found');
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение имени CSRF параметра
|
||||
*/
|
||||
getCsrfTokenName() {
|
||||
// Из data-атрибута
|
||||
if (this.container.dataset.csrfTokenName) {
|
||||
return this.container.dataset.csrfTokenName;
|
||||
}
|
||||
|
||||
// Из скрытого input внутри контейнера
|
||||
const csrfInput = this.container.querySelector('input[name*="csrf"]');
|
||||
if (csrfInput) {
|
||||
return csrfInput.name;
|
||||
}
|
||||
|
||||
// Из скрытого input на странице
|
||||
const globalCsrfInput = document.querySelector('input[name*="csrf"]');
|
||||
if (globalCsrfInput) {
|
||||
return globalCsrfInput.name;
|
||||
}
|
||||
|
||||
// Значения по умолчанию
|
||||
return 'csrf_test_name';
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление CSRF токена из ответа сервера
|
||||
*/
|
||||
updateCsrfToken(response) {
|
||||
// CodeIgniter 4 может возвращать новый токен в заголовке
|
||||
const csrfHeader = response.headers.get('X-CSRF-TOKEN');
|
||||
if (csrfHeader) {
|
||||
// Обновляем все найденные CSRF inputs
|
||||
document.querySelectorAll('input[name*="csrf"]').forEach(input => {
|
||||
input.value = csrfHeader;
|
||||
});
|
||||
// Также обновляем data-атрибут контейнера
|
||||
this.container.dataset.csrfToken = csrfHeader;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка данных таблицы
|
||||
*/
|
||||
async loadData() {
|
||||
const params = this.buildParams();
|
||||
const csrfToken = this.getCsrfToken();
|
||||
const csrfTokenName = this.getCsrfTokenName();
|
||||
|
||||
// Добавляем CSRF токен в параметры запроса
|
||||
const url = `${this.options.url}?${params}&${csrfTokenName}=${encodeURIComponent(csrfToken)}`;
|
||||
|
||||
// Показываем лоадер в tbody
|
||||
const tableBody = this.container.querySelector('tbody');
|
||||
if (tableBody) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="100" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
// Обновляем CSRF токен из ответа
|
||||
this.updateCsrfToken(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
this.updateTable(html);
|
||||
|
||||
} catch (error) {
|
||||
console.error('DataTable: Ошибка загрузки данных:', error);
|
||||
this.showError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление таблицы
|
||||
* Парсит HTML и заменяет только tbody/tfoot, сохраняя thead
|
||||
*/
|
||||
updateTable(html) {
|
||||
// Сохраняем состояние полей поиска
|
||||
const searchInputs = this.container.querySelectorAll('[data-search-input]');
|
||||
const searchStates = {};
|
||||
searchInputs.forEach(input => {
|
||||
searchStates[input.dataset.searchInput] = input.value;
|
||||
});
|
||||
|
||||
// Ищем tbody и tfoot в ответе
|
||||
const tbodyMatch = html.match(/<tbody[^>]*>[\s\S]*?<\/tbody>/i);
|
||||
const tfootMatch = html.match(/<tfoot[^>]*>[\s\S]*?<\/tfoot>/i);
|
||||
|
||||
const oldTableBody = this.container.querySelector('tbody');
|
||||
const oldTableFooter = this.container.querySelector('tfoot');
|
||||
const table = this.container.querySelector('table');
|
||||
|
||||
if (tbodyMatch && oldTableBody && table) {
|
||||
// Извлекаем содержимое tbody (без тегов tbody)
|
||||
const tbodyContent = tbodyMatch[0].replace(/<\/?tbody[^>]*>/gi, '');
|
||||
|
||||
// Очищаем старый tbody и заполняем новым содержимым
|
||||
oldTableBody.innerHTML = tbodyContent;
|
||||
|
||||
// Обновляем tfoot
|
||||
if (tfootMatch) {
|
||||
const tfootContent = tfootMatch[0].replace(/<\/?tfoot[^>]*>/gi, '');
|
||||
if (oldTableFooter) {
|
||||
oldTableFooter.innerHTML = tfootContent;
|
||||
} else {
|
||||
const newTfoot = document.createElement('tfoot');
|
||||
newTfoot.innerHTML = tfootContent;
|
||||
table.appendChild(newTfoot);
|
||||
}
|
||||
} else if (oldTableFooter) {
|
||||
oldTableFooter.remove();
|
||||
}
|
||||
} else {
|
||||
// Нет tbody или нет table - полная замена контейнера
|
||||
this.container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Восстанавливаем состояние полей поиска
|
||||
this.restoreSearchStates(searchStates);
|
||||
|
||||
// Обновляем URL
|
||||
this.updateURL();
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстановление состояния полей поиска
|
||||
*/
|
||||
restoreSearchStates(savedStates = null) {
|
||||
const inputs = this.container.querySelectorAll('[data-search-input]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
const column = input.dataset.searchInput;
|
||||
const value = savedStates && savedStates[column] !== undefined
|
||||
? savedStates[column]
|
||||
: input.value.trim();
|
||||
|
||||
const th = input.closest('th');
|
||||
const headerText = th.querySelector('[data-header-text]');
|
||||
|
||||
if (headerText) {
|
||||
if (value) {
|
||||
headerText.style.display = 'none';
|
||||
input.style.display = 'inline';
|
||||
input.value = value;
|
||||
} else {
|
||||
input.style.display = 'none';
|
||||
headerText.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление URL
|
||||
*/
|
||||
updateURL() {
|
||||
const params = this.buildParams();
|
||||
// Убираем существующие параметры из URL и добавляем новые
|
||||
const baseUrl = this.options.url.split('?')[0];
|
||||
const url = `${baseUrl}?${params}`;
|
||||
|
||||
window.history.pushState(this.state, '', url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирование параметров запроса
|
||||
*/
|
||||
buildParams() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', this.state.page);
|
||||
params.set('perPage', this.state.perPage);
|
||||
|
||||
if (this.state.sort) {
|
||||
params.set('sort', this.state.sort);
|
||||
params.set('order', this.state.order);
|
||||
}
|
||||
|
||||
Object.entries(this.state.filters).forEach(([key, value]) => {
|
||||
if (value && value.trim()) {
|
||||
params.set(`filters[${key}]`, value);
|
||||
}
|
||||
});
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать ошибку загрузки
|
||||
*/
|
||||
showError() {
|
||||
const tableBody = this.container.querySelector('tbody');
|
||||
if (tableBody) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="100" class="alert alert-danger m-3">
|
||||
Ошибка загрузки данных. Пожалуйста, обновите страницу.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Установка параметров фильтрации извне
|
||||
*/
|
||||
setFilter(column, value) {
|
||||
this.state.filters[column] = value;
|
||||
this.state.page = 1;
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Установка количества записей на странице
|
||||
*/
|
||||
setPerPage(value) {
|
||||
this.state.perPage = parseInt(value);
|
||||
this.state.page = 1;
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Переход на страницу
|
||||
*/
|
||||
goToPage(page) {
|
||||
this.state.page = Math.max(1, parseInt(page));
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
// Экспорт для глобального использования
|
||||
window.DataTable = DataTable;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use CodeIgniter\Boot;
|
||||
use Config\Paths;
|
||||
|
||||
/*
|
||||
*---------------------------------------------------------------
|
||||
* CHECK PHP VERSION
|
||||
*---------------------------------------------------------------
|
||||
*/
|
||||
|
||||
$minPhpVersion = '8.1'; // If you update this, don't forget to update `spark`.
|
||||
if (version_compare(PHP_VERSION, $minPhpVersion, '<')) {
|
||||
$message = sprintf(
|
||||
'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s',
|
||||
$minPhpVersion,
|
||||
PHP_VERSION,
|
||||
);
|
||||
|
||||
header('HTTP/1.1 503 Service Unavailable.', true, 503);
|
||||
echo $message;
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/*
|
||||
*---------------------------------------------------------------
|
||||
* SET THE CURRENT DIRECTORY
|
||||
*---------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Path to the front controller (this file)
|
||||
define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR);
|
||||
|
||||
// Ensure the current directory is pointing to the front controller's directory
|
||||
if (getcwd() . DIRECTORY_SEPARATOR !== FCPATH) {
|
||||
chdir(FCPATH);
|
||||
}
|
||||
|
||||
/*
|
||||
*---------------------------------------------------------------
|
||||
* BOOTSTRAP THE APPLICATION
|
||||
*---------------------------------------------------------------
|
||||
* This process sets up the path constants, loads and registers
|
||||
* our autoloader, along with Composer's, loads our constants
|
||||
* and fires up an environment-specific bootstrapping.
|
||||
*/
|
||||
|
||||
// LOAD OUR PATHS CONFIG FILE
|
||||
// This is the line that might need to be changed, depending on your folder structure.
|
||||
require FCPATH . '../app/Config/Paths.php';
|
||||
// ^^^ Change this line if you move your application folder
|
||||
|
||||
$paths = new Paths();
|
||||
|
||||
// LOAD THE FRAMEWORK BOOTSTRAP FILE
|
||||
require $paths->systemDirectory . '/Boot.php';
|
||||
|
||||
exit(Boot::bootWeb($paths));
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Reference in New Issue
Block a user