Продвинутые примеры
Комплексные примеры использования MNRFY Framework для решения реальных задач
1. Многоуровневое меню с правами доступа
Структура данных
<!-- Получение меню с проверкой прав -->
{% php %}
$menuItems = [
[
'title' => 'Главная',
'url' => '/',
'icon' => 'home',
'permission' => null
],
[
'title' => 'Пользователи',
'icon' => 'users',
'permission' => 'users.view',
'children' => [
[
'title' => 'Список пользователей',
'url' => '/admin/users',
'permission' => 'users.list'
],
[
'title' => 'Создать пользователя',
'url' => '/admin/users/create',
'permission' => 'users.create'
],
[
'title' => 'Роли и права',
'url' => '/admin/roles',
'permission' => 'roles.manage'
]
]
],
[
'title' => 'Контент',
'icon' => 'newspaper',
'permission' => 'content.view',
'children' => [
[
'title' => 'Статьи',
'url' => '/admin/articles',
'permission' => 'articles.list',
'badge' => db('1752665380840')->countItems('articles', ['status' => 'draft'])
],
[
'title' => 'Категории',
'url' => '/admin/categories',
'permission' => 'categories.manage'
],
[
'title' => 'Комментарии',
'url' => '/admin/comments',
'permission' => 'comments.moderate',
'badge' => db('1752665380840')->countItems('comments', ['status' => 'pending'])
]
]
],
[
'title' => 'Система',
'icon' => 'cogs',
'permission' => 'system.view',
'children' => [
[
'title' => 'Настройки',
'url' => '/admin/settings',
'permission' => 'system.settings'
],
[
'title' => 'Логи',
'url' => '/admin/logs',
'permission' => 'system.logs'
],
[
'title' => 'Кеш',
'url' => '/admin/cache',
'permission' => 'system.cache'
]
]
]
];
{% endphp %}
Рекурсивный компонент меню
<!-- components/admin-menu.html -->
{% macro render_menu_item(item, level) %}
{% if not item.permission or can(item.permission) %}
<li class="menu-item level-{{ level }} {{ item.children ? 'has-children' : '' }}">
{% if item.url %}
<a href="{{ item.url }}"
class="menu-link {{ is_current_path(item.url) ? 'active' : '' }}"
{% if item.children %}onclick="toggleSubmenu(this)"{% endif %}>
{% if item.icon %}
<i class="fas fa-{{ item.icon }}"></i>
{% endif %}
<span class="menu-text">{{ t('menu.' ~ item.title | lower | replace(' ', '_')) }}</span>
{% if item.badge and item.badge > 0 %}
<span class="menu-badge">{{ item.badge }}</span>
{% endif %}
{% if item.children %}
<i class="fas fa-chevron-down menu-toggle"></i>
{% endif %}
</a>
{% else %}
<div class="menu-header" onclick="toggleSubmenu(this)">
{% if item.icon %}
<i class="fas fa-{{ item.icon }}"></i>
{% endif %}
<span>{{ t('menu.' ~ item.title | lower | replace(' ', '_')) }}</span>
<i class="fas fa-chevron-down menu-toggle"></i>
</div>
{% endif %}
{% if item.children %}
{% set visibleChildren = [] %}
{% foreach item.children as child %}
{% if not child.permission or can(child.permission) %}
{% set visibleChildren = visibleChildren | push(child) %}
{% endif %}
{% endforeach %}
{% if visibleChildren | length > 0 %}
<ul class="submenu level-{{ level + 1 }}">
{% foreach visibleChildren as child %}
{{ _self.render_menu_item(child, level + 1) }}
{% endforeach %}
</ul>
{% endif %}
{% endif %}
</li>
{% endif %}
{% endmacro %}
<nav class="admin-sidebar">
<div class="sidebar-header">
<h3>{{ config('app.name') }}</h3>
<p>Панель управления</p>
</div>
<ul class="main-menu">
{% foreach menuItems as item %}
{{ _self.render_menu_item(item, 0) }}
{% endforeach %}
</ul>
<div class="sidebar-footer">
<div class="user-info">
<img src="{{ auth().avatar ?? asset('images/default-avatar.png') }}" alt="Avatar">
<div>
<strong>{{ auth().name }}</strong>
<small>{{ t('roles.' ~ auth().role) }}</small>
</div>
</div>
</div>
</nav>
<script>
function toggleSubmenu(element) {
const submenu = element.nextElementSibling;
const toggle = element.querySelector('.menu-toggle');
if (submenu && submenu.classList.contains('submenu')) {
submenu.classList.toggle('open');
toggle.classList.toggle('rotated');
element.classList.toggle('active');
}
}
</script>
2. Продвинутая система фильтрации и поиска
Универсальная таблица с фильтрами
<!-- components/advanced-datatable.html -->
{% set currentFilters = request.all() %}
{% set sortField = request.input('sort', default_sort ?? 'created_at') %}
{% set sortDirection = request.input('direction', 'desc') %}
{% set searchQuery = request.input('search', '') %}
{% set perPage = request.input('per_page', 25) %}
<div class="advanced-datatable">
<div class="datatable-controls">
<!-- Поиск -->
<div class="search-section">
<div class="search-box">
<input type="text"
id="globalSearch"
placeholder="{{ t('table.search_placeholder') }}"
value="{{ searchQuery }}"
onkeyup="debounceSearch(this.value)">
<button type="button" onclick="clearSearch()">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Быстрые фильтры -->
{% if quick_filters %}
<div class="quick-filters">
{% foreach quick_filters as filter %}
<button type="button"
class="filter-btn {{ currentFilters[filter.field] == filter.value ? 'active' : '' }}"
onclick="applyQuickFilter('{{ filter.field }}', '{{ filter.value }}')">
{% if filter.icon %}
<i class="fas fa-{{ filter.icon }}"></i>
{% endif %}
{{ filter.label }}
{% if filter.count %}
<span class="count">{{ filter.count }}</span>
{% endif %}
</button>
{% endforeach %}
</div>
{% endif %}
</div>
<!-- Расширенные фильтры -->
<div class="advanced-filters" id="advancedFilters">
<form method="GET" class="filters-form">
<input type="hidden" name="search" value="{{ searchQuery }}">
<input type="hidden" name="sort" value="{{ sortField }}">
<input type="hidden" name="direction" value="{{ sortDirection }}">
<div class="filters-grid">
{% foreach filters as filter %}
<div class="filter-group">
<label>{{ filter.label }}</label>
{% if filter.type == 'select' %}
<select name="{{ filter.field }}">
<option value="">{{ t('filter.all') }}</option>
{% foreach filter.options as value, label %}
<option value="{{ value }}"
{{ currentFilters[filter.field] == value ? 'selected' : '' }}>
{{ label }}
</option>
{% endforeach %}
</select>
{% elseif filter.type == 'date_range' %}
<div class="date-range">
<input type="date"
name="{{ filter.field }}_from"
value="{{ currentFilters[filter.field ~ '_from'] }}"
placeholder="От">
<input type="date"
name="{{ filter.field }}_to"
value="{{ currentFilters[filter.field ~ '_to'] }}"
placeholder="До">
</div>
{% elseif filter.type == 'number_range' %}
<div class="number-range">
<input type="number"
name="{{ filter.field }}_min"
value="{{ currentFilters[filter.field ~ '_min'] }}"
placeholder="Мин.">
<input type="number"
name="{{ filter.field }}_max"
value="{{ currentFilters[filter.field ~ '_max'] }}"
placeholder="Макс.">
</div>
{% elseif filter.type == 'multiselect' %}
<div class="multiselect" data-field="{{ filter.field }}">
{% set selectedValues = currentFilters[filter.field] | split(',') %}
{% foreach filter.options as value, label %}
<label class="checkbox-label">
<input type="checkbox"
name="{{ filter.field }}[]"
value="{{ value }}"
{{ value in selectedValues ? 'checked' : '' }}>
{{ label }}
</label>
{% endforeach %}
</div>
{% endif %}
</div>
{% endforeach %}
</div>
<div class="filters-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-filter"></i> {{ t('button.apply_filters') }}
</button>
<button type="button" class="btn btn-secondary" onclick="clearAllFilters()">
<i class="fas fa-times"></i> {{ t('button.clear_filters') }}
</button>
<button type="button" class="btn btn-info" onclick="saveFilterPreset()">
<i class="fas fa-bookmark"></i> {{ t('button.save_preset') }}
</button>
</div>
</form>
</div>
<!-- Панель управления -->
<div class="table-toolbar">
<div class="toolbar-left">
<button type="button" class="btn btn-secondary" onclick="toggleFilters()">
<i class="fas fa-sliders-h"></i> {{ t('table.advanced_filters') }}
</button>
<div class="view-modes">
<button type="button" class="btn {{ view_mode == 'table' ? 'active' : '' }}"
onclick="changeViewMode('table')">
<i class="fas fa-table"></i>
</button>
<button type="button" class="btn {{ view_mode == 'cards' ? 'active' : '' }}"
onclick="changeViewMode('cards')">
<i class="fas fa-th"></i>
</button>
<button type="button" class="btn {{ view_mode == 'list' ? 'active' : '' }}"
onclick="changeViewMode('list')">
<i class="fas fa-list"></i>
</button>
</div>
</div>
<div class="toolbar-right">
<!-- Количество на странице -->
<select name="per_page" onchange="changePerPage(this.value)" class="per-page-select">
{% for count in [10, 25, 50, 100] %}
<option value="{{ count }}" {{ perPage == count ? 'selected' : '' }}>
{{ count }} / {{ t('table.page') }}
</option>
{% endfor %}
</select>
<!-- Экспорт -->
<div class="dropdown">
<button type="button" class="btn btn-outline-primary dropdown-toggle">
<i class="fas fa-download"></i> {{ t('table.export') }}
</button>
<div class="dropdown-menu">
<a href="#" onclick="exportData('csv')">
<i class="fas fa-file-csv"></i> CSV
</a>
<a href="#" onclick="exportData('excel')">
<i class="fas fa-file-excel"></i> Excel
</a>
<a href="#" onclick="exportData('pdf')">
<i class="fas fa-file-pdf"></i> PDF
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Активные фильтры -->
{% set activeFilters = [] %}
{% foreach currentFilters as key, value %}
{% if value and key not in ['page', 'sort', 'direction'] %}
{% set activeFilters = activeFilters | push({'key': key, 'value': value}) %}
{% endif %}
{% endforeach %}
{% if activeFilters | length > 0 %}
<div class="active-filters">
<span>{{ t('table.active_filters') }}:</span>
{% foreach activeFilters as filter %}
<span class="filter-tag">
{{ filter.key }}: {{ filter.value }}
<button type="button" onclick="removeFilter('{{ filter.key }}')">×</button>
</span>
{% endforeach %}
<button type="button" class="clear-all-filters" onclick="clearAllFilters()">
{{ t('table.clear_all') }}
</button>
</div>
{% endif %}
</div>
3. Сложная система уведомлений в реальном времени
Компонент центра уведомлений
<!-- components/notification-center.html -->
{% set unreadCount = db('1752665380840')->countItems('notifications', {
'user_id': auth().id,
'read_at': null
}) %}
<div class="notification-center">
<div class="notification-trigger" onclick="toggleNotifications()">
<i class="fas fa-bell"></i>
{% if unreadCount > 0 %}
<span class="notification-badge">
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
{% endif %}
</div>
<div class="notification-dropdown" id="notificationDropdown">
<div class="notification-header">
<h3>{{ t('notifications.title') }}</h3>
<div class="notification-actions">
{% if unreadCount > 0 %}
<button type="button" onclick="markAllAsRead()" class="btn-link">
{{ t('notifications.mark_all_read') }}
</button>
{% endif %}
<button type="button" onclick="refreshNotifications()" class="btn-link">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
<div class="notification-filters">
<button type="button" class="filter-btn active" data-filter="all">
{{ t('notifications.all') }}
</button>
<button type="button" class="filter-btn" data-filter="unread">
{{ t('notifications.unread') }} ({{ unreadCount }})
</button>
<button type="button" class="filter-btn" data-filter="system">
{{ t('notifications.system') }}
</button>
<button type="button" class="filter-btn" data-filter="messages">
{{ t('notifications.messages') }}
</button>
</div>
<div class="notification-list" id="notificationList">
{% set notifications = db('1752665380840')->getItems('notifications', {
'user_id': auth().id
}, ['*'], 'created_at', 'DESC', null, 50) %}
{% if notifications | length > 0 %}
{% foreach notifications as notification %}
{% component 'notification-item' with {'notification': notification} %}
{% endforeach %}
{% else %}
<div class="no-notifications">
<i class="fas fa-bell-slash fa-2x"></i>
<p>{{ t('notifications.no_notifications') }}</p>
</div>
{% endif %}
</div>
<div class="notification-footer">
<a href="/notifications" class="btn btn-primary btn-block">
{{ t('notifications.view_all') }}
</a>
</div>
</div>
</div>
<!-- WebSocket подключение для real-time уведомлений -->
<script>
class NotificationCenter {
constructor() {
this.websocket = null;
this.reconnectInterval = 5000;
this.maxReconnectAttempts = 5;
this.reconnectAttempts = 0;
this.init();
this.connectWebSocket();
}
init() {
// Инициализация обработчиков событий
document.addEventListener('click', (e) => {
if (!e.target.closest('.notification-center')) {
this.closeDropdown();
}
});
// Фильтры уведомлений
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.filterNotifications(e.target.dataset.filter);
});
});
// Проверка уведомлений каждые 30 секунд (fallback)
setInterval(() => {
this.checkNotifications();
}, 30000);
}
connectWebSocket() {
try {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws/notifications`;
this.websocket = new WebSocket(wsUrl);
this.websocket.onopen = () => {
console.log('Notification WebSocket connected');
this.reconnectAttempts = 0;
// Аутентификация через WebSocket
this.websocket.send(JSON.stringify({
type: 'auth',
token: document.querySelector('[name="csrf-token"]').content
}));
};
this.websocket.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleNotification(data);
};
this.websocket.onclose = () => {
console.log('Notification WebSocket disconnected');
this.reconnect();
};
this.websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Failed to connect WebSocket:', error);
this.reconnect();
}
}
reconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
setTimeout(() => {
this.connectWebSocket();
}, this.reconnectInterval);
}
}
handleNotification(data) {
switch(data.type) {
case 'new_notification':
this.addNotification(data.notification);
this.updateBadge();
this.showToast(data.notification);
break;
case 'notification_read':
this.markAsRead(data.notificationId);
this.updateBadge();
break;
case 'bulk_read':
this.markAllAsRead();
break;
}
}
addNotification(notification) {
const list = document.getElementById('notificationList');
const item = this.createNotificationItem(notification);
list.insertBefore(item, list.firstChild);
// Анимация появления
item.classList.add('notification-new');
setTimeout(() => {
item.classList.remove('notification-new');
}, 3000);
}
createNotificationItem(notification) {
const div = document.createElement('div');
div.className = `notification-item ${notification.read_at ? '' : 'unread'} ${notification.type}`;
div.dataset.id = notification.id;
const timeAgo = this.timeAgo(new Date(notification.created_at * 1000));
div.innerHTML = `
<div class="notification-icon">
<i class="fas fa-${this.getNotificationIcon(notification.type)}"></i>
</div>
<div class="notification-content">
<div class="notification-title">${notification.title}</div>
<div class="notification-message">${notification.message}</div>
<div class="notification-time">${timeAgo}</div>
</div>
<div class="notification-actions">
${notification.action_url ? `<a href="${notification.action_url}" class="btn-link">Просмотр</a>` : ''}
<button type="button" onclick="markAsRead(${notification.id})" class="btn-link">
<i class="fas fa-check"></i>
</button>
</div>
`;
return div;
}
showToast(notification) {
// Создание toast уведомления
const toast = document.createElement('div');
toast.className = 'notification-toast';
toast.innerHTML = `
<div class="toast-icon">
<i class="fas fa-${this.getNotificationIcon(notification.type)}"></i>
</div>
<div class="toast-content">
<strong>${notification.title}</strong>
<p>${notification.message}</p>
</div>
<button type="button" class="toast-close" onclick="this.parentElement.remove()">
×
</button>
`;
document.body.appendChild(toast);
// Автоматическое скрытие через 5 секунд
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, 5000);
}
getNotificationIcon(type) {
const icons = {
'system': 'cog',
'message': 'envelope',
'order': 'shopping-cart',
'payment': 'credit-card',
'security': 'shield-alt',
'promotion': 'gift'
};
return icons[type] || 'bell';
}
timeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'только что';
if (seconds < 3600) return Math.floor(seconds / 60) + ' мин. назад';
if (seconds < 86400) return Math.floor(seconds / 3600) + ' ч. назад';
if (seconds < 2592000) return Math.floor(seconds / 86400) + ' дн. назад';
return date.toLocaleDateString();
}
async updateBadge() {
try {
const response = await fetch('/api/notifications/unread-count');
const data = await response.json();
const badge = document.querySelector('.notification-badge');
if (data.count > 0) {
if (!badge) {
const newBadge = document.createElement('span');
newBadge.className = 'notification-badge';
document.querySelector('.notification-trigger').appendChild(newBadge);
}
document.querySelector('.notification-badge').textContent =
data.count > 99 ? '99+' : data.count;
} else if (badge) {
badge.remove();
}
} catch (error) {
console.error('Failed to update badge:', error);
}
}
}
// Инициализация
const notificationCenter = new NotificationCenter();
function toggleNotifications() {
const dropdown = document.getElementById('notificationDropdown');
dropdown.classList.toggle('show');
if (dropdown.classList.contains('show')) {
notificationCenter.checkNotifications();
}
}
async function markAsRead(notificationId) {
try {
const response = await fetch(`/api/notifications/${notificationId}/read`, {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const item = document.querySelector(`[data-id="${notificationId}"]`);
if (item) {
item.classList.remove('unread');
}
notificationCenter.updateBadge();
}
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
async function markAllAsRead() {
try {
const response = await fetch('/api/notifications/mark-all-read', {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
}
});
if (response.ok) {
document.querySelectorAll('.notification-item').forEach(item => {
item.classList.remove('unread');
});
notificationCenter.updateBadge();
}
} catch (error) {
console.error('Failed to mark all as read:', error);
}
}
</script>
4. Система управления файлами с drag&drop
Файловый менеджер
<!-- components/file-manager.html -->
<div class="file-manager" id="fileManager">
<div class="file-manager-toolbar">
<div class="toolbar-left">
<div class="breadcrumb">
{% set pathParts = current_path | split('/') | filter %}
<a href="#" onclick="navigateToPath('/')" class="breadcrumb-item">
<i class="fas fa-home"></i>
</a>
{% set buildPath = '' %}
{% foreach pathParts as part %}
{% set buildPath = buildPath ~ '/' ~ part %}
<span class="breadcrumb-separator">/</span>
<a href="#" onclick="navigateToPath('{{ buildPath }}')" class="breadcrumb-item">
{{ part }}
</a>
{% endforeach %}
</div>
</div>
<div class="toolbar-right">
<button type="button" class="btn btn-primary" onclick="openUploadModal()">
<i class="fas fa-upload"></i> {{ t('file.upload') }}
</button>
<button type="button" class="btn btn-secondary" onclick="createFolder()">
<i class="fas fa-folder-plus"></i> {{ t('file.new_folder') }}
</button>
<div class="view-toggle">
<button type="button" class="btn {{ view_mode == 'grid' ? 'active' : '' }}"
onclick="setViewMode('grid')">
<i class="fas fa-th"></i>
</button>
<button type="button" class="btn {{ view_mode == 'list' ? 'active' : '' }}"
onclick="setViewMode('list')">
<i class="fas fa-list"></i>
</button>
</div>
</div>
</div>
<!-- Файловая область с drag&drop -->
<div class="file-area {{ view_mode }}-view"
id="fileArea"
ondrop="handleDrop(event)"
ondragover="handleDragOver(event)"
ondragenter="handleDragEnter(event)"
ondragleave="handleDragLeave(event)">
{% set files = getDirectoryContents(current_path) %}
{% if files | length > 0 %}
{% foreach files as file %}
<div class="file-item {{ file.type }}"
data-path="{{ file.path }}"
data-type="{{ file.type }}"
draggable="true"
ondragstart="handleFileDragStart(event)"
onclick="selectFile(this)"
ondblclick="openFile(this)">
<div class="file-thumbnail">
{% if file.type == 'image' and file.thumbnail %}
<img src="{{ file.thumbnail }}" alt="{{ file.name }}" loading="lazy">
{% else %}
<i class="fas fa-{{ getFileIcon(file) }} file-icon"></i>
{% endif %}
{% if file.type != 'folder' %}
<div class="file-overlay">
<button type="button" class="btn btn-sm btn-primary"
onclick="previewFile('{{ file.path }}')">
<i class="fas fa-eye"></i>
</button>
<button type="button" class="btn btn-sm btn-danger"
onclick="deleteFile('{{ file.path }}')">
<i class="fas fa-trash"></i>
</button>
</div>
{% endif %}
</div>
<div class="file-info">
<div class="file-name" title="{{ file.name }}">
{{ file.name | truncate(20) }}
</div>
{% if file.type != 'folder' %}
<div class="file-size">{{ file.size | filesize }}</div>
<div class="file-date">{{ file.modified | date('d.m.Y') }}</div>
{% endif %}
</div>
<div class="file-actions">
<div class="dropdown">
<button type="button" class="btn btn-sm" onclick="toggleFileMenu(this)">
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="dropdown-menu">
{% if file.type == 'folder' %}
<a href="#" onclick="openFolder('{{ file.path }}')">
<i class="fas fa-folder-open"></i> {{ t('file.open') }}
</a>
{% else %}
<a href="{{ file.download_url }}" download>
<i class="fas fa-download"></i> {{ t('file.download') }}
</a>
<a href="#" onclick="copyFileUrl('{{ file.public_url }}')">
<i class="fas fa-link"></i> {{ t('file.copy_link') }}
</a>
{% endif %}
<a href="#" onclick="renameFile('{{ file.path }}', '{{ file.name }}')">
<i class="fas fa-edit"></i> {{ t('file.rename') }}
</a>
<a href="#" onclick="deleteFile('{{ file.path }}')" class="text-danger">
<i class="fas fa-trash"></i> {{ t('file.delete') }}
</a>
</div>
</div>
</div>
</div>
{% endforeach %}
{% else %}
<div class="empty-folder">
<i class="fas fa-folder-open fa-4x"></i>
<h3>{{ t('file.empty_folder') }}</h3>
<p>{{ t('file.empty_folder_hint') }}</p>
</div>
{% endif %}
<!-- Drag&Drop область -->
<div class="drag-overlay" id="dragOverlay">
<div class="drag-content">
<i class="fas fa-cloud-upload-alt fa-4x"></i>
<h3>{{ t('file.drop_files_here') }}</h3>
</div>
</div>
</div>
<!-- Информационная панель -->
<div class="file-info-panel" id="fileInfoPanel">
<h4>{{ t('file.properties') }}</h4>
<div id="fileProperties">
{{ t('file.select_file_for_info') }}
</div>
</div>
</div>
<!-- Модальное окно загрузки -->
<div class="modal" id="uploadModal">
<div class="modal-content">
<div class="modal-header">
<h3>{{ t('file.upload_files') }}</h3>
<button type="button" class="modal-close" onclick="closeUploadModal()">×</button>
</div>
<div class="modal-body">
<div class="upload-area" id="uploadArea">
<input type="file" id="fileInput" multiple accept="*/*" onchange="handleFileSelect(event)">
<div class="upload-prompt">
<i class="fas fa-cloud-upload-alt fa-3x"></i>
<h4>{{ t('file.drag_or_click') }}</h4>
<p>{{ t('file.max_size_info', {'size': max_file_size}) }}</p>
</div>
</div>
<div class="upload-queue" id="uploadQueue"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeUploadModal()">
{{ t('button.cancel') }}
</button>
<button type="button" class="btn btn-primary" onclick="startUpload()" id="uploadBtn" disabled>
{{ t('file.start_upload') }}
</button>
</div>
</div>
</div>
<script>
class FileManager {
constructor() {
this.currentPath = '/';
this.selectedFiles = [];
this.uploadQueue = [];
this.dragCounter = 0;
this.init();
}
init() {
// Инициализация drag&drop для всего окна
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.addEventListener(eventName, this.preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
document.addEventListener(eventName, this.highlight.bind(this), false);
});
['dragleave', 'drop'].forEach(eventName => {
document.addEventListener(eventName, this.unhighlight.bind(this), false);
});
document.addEventListener('drop', this.handleGlobalDrop.bind(this), false);
}
preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
highlight() {
this.dragCounter++;
document.getElementById('dragOverlay').classList.add('show');
}
unhighlight() {
this.dragCounter--;
if (this.dragCounter === 0) {
document.getElementById('dragOverlay').classList.remove('show');
}
}
handleGlobalDrop(e) {
this.dragCounter = 0;
this.unhighlight();
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handleFiles(files);
}
}
async handleFiles(files) {
const fileArray = Array.from(files);
for (const file of fileArray) {
// Проверка размера файла
if (file.size > {{ max_file_size_bytes }}) {
this.showError(`Файл ${file.name} слишком большой. Максимум {{ max_file_size }}`);
continue;
}
// Проверка типа файла
if (!this.isAllowedFileType(file)) {
this.showError(`Тип файла ${file.name} не разрешен`);
continue;
}
await this.uploadFile(file);
}
// Обновляем файловый менеджер
this.refreshCurrentDirectory();
}
async uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('path', this.currentPath);
formData.append('_token', document.querySelector('[name="csrf-token"]').content);
try {
// Создаем индикатор прогресса
const progressId = this.createProgressIndicator(file.name);
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const result = await response.json();
if (result.success) {
this.updateProgressIndicator(progressId, 100, 'Загружен');
this.showSuccess(`Файл ${file.name} успешно загружен`);
// Добавляем новый файл в интерфейс
this.addFileToDisplay(result.file);
} else {
this.updateProgressIndicator(progressId, 0, 'Ошибка');
this.showError(result.message || 'Ошибка загрузки файла');
}
} catch (error) {
console.error('Upload error:', error);
this.showError('Ошибка загрузки файла');
}
}
createProgressIndicator(fileName) {
const progressId = 'progress_' + Date.now();
const progressHTML = `
<div id="${progressId}" class="upload-progress">
<div class="file-name">${fileName}</div>
<div class="progress-bar">
<div class="progress-fill" style="width: 0%"></div>
</div>
<div class="progress-text">0%</div>
</div>
`;
// Добавляем в очередь загрузки или в специальную область
const container = document.getElementById('uploadQueue') || document.body;
container.insertAdjacentHTML('beforeend', progressHTML);
return progressId;
}
updateProgressIndicator(progressId, percent, status) {
const progressElement = document.getElementById(progressId);
if (progressElement) {
const fill = progressElement.querySelector('.progress-fill');
const text = progressElement.querySelector('.progress-text');
fill.style.width = percent + '%';
text.textContent = status || (percent + '%');
if (percent === 100) {
setTimeout(() => {
progressElement.remove();
}, 2000);
}
}
}
isAllowedFileType(file) {
const allowedTypes = {{ allowed_file_types | json }};
const fileExtension = file.name.split('.').pop().toLowerCase();
return allowedTypes.includes('*') || allowedTypes.includes(fileExtension);
}
showError(message) {
this.showNotification(message, 'error');
}
showSuccess(message) {
this.showNotification(message, 'success');
}
showNotification(message, type) {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('show');
}, 100);
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
notification.remove();
}, 300);
}, 5000);
}
}
// Инициализация файлового менеджера
const fileManager = new FileManager();
// Глобальные функции для интерфейса
function navigateToPath(path) {
fileManager.currentPath = path;
window.location.href = `?path=${encodeURIComponent(path)}`;
}
function selectFile(element) {
// Снимаем выделение с других файлов
document.querySelectorAll('.file-item.selected').forEach(item => {
item.classList.remove('selected');
});
// Выделяем текущий файл
element.classList.add('selected');
// Показываем информацию о файле
showFileInfo(element);
}
function showFileInfo(fileElement) {
const filePath = fileElement.dataset.path;
const fileName = fileElement.querySelector('.file-name').textContent;
const fileType = fileElement.dataset.type;
// Загружаем детальную информацию о файле
fetch(`/api/files/info?path=${encodeURIComponent(filePath)}`)
.then(response => response.json())
.then(data => {
const infoPanel = document.getElementById('fileProperties');
infoPanel.innerHTML = `
<div class="property"><strong>Имя:</strong> ${data.name}</div>
<div class="property"><strong>Размер:</strong> ${data.size}</div>
<div class="property"><strong>Тип:</strong> ${data.type}</div>
<div class="property"><strong>Изменен:</strong> ${data.modified}</div>
${data.dimensions ? `<div class="property"><strong>Размеры:</strong> ${data.dimensions}</div>` : ''}
`;
});
}
function deleteFile(filePath) {
if (!confirm('{{ t("file.confirm_delete") }}')) return;
fetch('/api/files/delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: JSON.stringify({ path: filePath })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Удаляем элемент из интерфейса
const fileElement = document.querySelector(`[data-path="${filePath}"]`);
if (fileElement) {
fileElement.remove();
}
fileManager.showSuccess('{{ t("file.deleted_successfully") }}');
} else {
fileManager.showError(data.message || '{{ t("file.delete_error") }}');
}
})
.catch(error => {
fileManager.showError('{{ t("file.delete_error") }}');
});
}
</script>
5. Интерактивная аналитическая панель
Dashboard с графиками и метриками в реальном времени
<!-- pages/analytics-dashboard.html -->
{% extends 'layouts/admin.html' %}
{% block title %}Аналитика - {{ parent() }}{% endblock %}
{% block page_styles %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.css">
<link rel="stylesheet" href="{{ asset('css/dashboard.css') }}">
{% endblock %}
{% block content %}
<div class="analytics-dashboard">
<!-- Панель фильтров -->
<div class="dashboard-filters">
<div class="filter-group">
<label>Период:</label>
<select id="periodFilter" onchange="updateDashboard()">
<option value="today">Сегодня</option>
<option value="week" selected>Неделя</option>
<option value="month">Месяц</option>
<option value="quarter">Квартал</option>
<option value="year">Год</option>
<option value="custom">Произвольный</option>
</select>
</div>
<div class="filter-group date-range" id="customDateRange" style="display: none;">
<input type="date" id="dateFrom" onchange="updateDashboard()">
<span>—</span>
<input type="date" id="dateTo" onchange="updateDashboard()">
</div>
<div class="filter-group">
<label>Сравнение:</label>
<select id="compareFilter" onchange="updateDashboard()">
<option value="">Без сравнения</option>
<option value="previous">С предыдущим периодом</option>
<option value="year_ago">С прошлым годом</option>
</select>
</div>
<div class="filter-actions">
<button type="button" class="btn btn-outline-primary" onclick="exportReport()">
<i class="fas fa-download"></i> Экспорт
</button>
<button type="button" class="btn btn-outline-secondary" onclick="scheduleReport()">
<i class="fas fa-clock"></i> Отчет по расписанию
</button>
</div>
</div>
<!-- КПИ карточки -->
<div class="kpi-grid" id="kpiGrid">
{% set kpiMetrics = getKPIMetrics(period, compare_period) %}
{% foreach kpiMetrics as metric %}
<div class="kpi-card {{ metric.trend }}" data-metric="{{ metric.key }}">
<div class="kpi-header">
<div class="kpi-icon">
<i class="fas fa-{{ metric.icon }}"></i>
</div>
<div class="kpi-actions">
<button type="button" class="btn-link" onclick="drillDown('{{ metric.key }}')">
<i class="fas fa-search-plus"></i>
</button>
</div>
</div>
<div class="kpi-content">
<div class="kpi-value">
{{ metric.formatted_value }}
<span class="kpi-unit">{{ metric.unit }}</span>
</div>
<div class="kpi-title">{{ metric.title }}</div>
{% if metric.change %}
<div class="kpi-change {{ metric.change > 0 ? 'positive' : (metric.change < 0 ? 'negative' : 'neutral') }}">
<i class="fas fa-{{ metric.change > 0 ? 'arrow-up' : (metric.change < 0 ? 'arrow-down' : 'minus') }}"></i>
{{ metric.change | abs | number(1) }}%
<span class="change-period">vs {{ metric.compare_period_name }}</span>
</div>
{% endif %}
</div>
<div class="kpi-sparkline">
<canvas id="sparkline_{{ metric.key }}" width="200" height="50"></canvas>
</div>
</div>
{% endforeach %}
</div>
<!-- Основные графики -->
<div class="charts-grid">
<div class="chart-container large">
<div class="chart-header">
<h3>Динамика основных метрик</h3>
<div class="chart-controls">
<div class="metric-toggles">
<label class="checkbox-label">
<input type="checkbox" checked data-metric="revenue">
<span class="metric-color" style="background: #3498db"></span>
Выручка
</label>
<label class="checkbox-label">
<input type="checkbox" checked data-metric="orders">
<span class="metric-color" style="background: #2ecc71"></span>
Заказы
</label>
<label class="checkbox-label">
<input type="checkbox" data-metric="users">
<span class="metric-color" style="background: #e74c3c"></span>
Пользователи
</label>
</div>
</div>
</div>
<div class="chart-body">
<canvas id="mainChart" width="800" height="400"></canvas>
</div>
</div>
<div class="chart-container medium">
<div class="chart-header">
<h3>Топ товаров</h3>
</div>
<div class="chart-body">
<canvas id="topProductsChart" width="400" height="400"></canvas>
</div>
</div>
<div class="chart-container medium">
<div class="chart-header">
<h3>Источники трафика</h3>
</div>
<div class="chart-body">
<canvas id="trafficSourcesChart" width="400" height="400"></canvas>
</div>
</div>
<div class="chart-container large">
<div class="chart-header">
<h3>Аналитика по регионам</h3>
</div>
<div class="chart-body">
<div id="regionMap" style="height: 400px;"></div>
</div>
</div>
</div>
<!-- Детальные таблицы -->
<div class="detailed-tables">
<div class="table-container">
<div class="table-header">
<h3>Последние заказы</h3>
<a href="/admin/orders" class="btn btn-outline-primary btn-sm">
Все заказы <i class="fas fa-arrow-right"></i>
</a>
</div>
{% set recentOrders = getRecentOrders(10) %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>№ заказа</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Статус</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
{% foreach recentOrders as order %}
<tr onclick="window.open('/admin/orders/{{ order.id }}', '_blank')" style="cursor: pointer;">
<td>#{{ order.number }}</td>
<td>
<div class="customer-info">
{% if order.customer.avatar %}
<img src="{{ order.customer.avatar }}" alt="" class="avatar-sm">
{% endif %}
{{ order.customer.name }}
</div>
</td>
<td>{{ order.total | money('₽') }}</td>
<td>
<span class="status-badge {{ order.status }}">
{{ t('order.status.' ~ order.status) }}
</span>
</td>
<td>{{ order.created_at | time_ago }}</td>
</tr>
{% endforeach %}
</tbody>
</table>
</div>
</div>
<div class="table-container">
<div class="table-header">
<h3>Активность пользователей</h3>
</div>
{% set userActivity = getUserActivityStats() %}
<div class="activity-list">
{% foreach userActivity as activity %}
<div class="activity-item">
<div class="activity-avatar">
{% if activity.user.avatar %}
<img src="{{ activity.user.avatar }}" alt="">
{% else %}
<div class="avatar-placeholder">
{{ activity.user.name | first | upper }}
</div>
{% endif %}
</div>
<div class="activity-content">
<div class="activity-text">
<strong>{{ activity.user.name }}</strong>
{{ t('activity.' ~ activity.type, activity.data) }}
</div>
<div class="activity-time">
{{ activity.created_at | time_ago }}
</div>
</div>
<div class="activity-icon">
<i class="fas fa-{{ getActivityIcon(activity.type) }}"></i>
</div>
</div>
{% endforeach %}
</div>
</div>
</div>
</div>
<!-- WebSocket для real-time обновлений -->
<script>
class AnalyticsDashboard {
constructor() {
this.charts = {};
this.websocket = null;
this.updateInterval = null;
this.init();
this.initCharts();
this.connectWebSocket();
this.startPeriodicUpdates();
}
init() {
// Обработчик изменения фильтров
document.getElementById('periodFilter').addEventListener('change', (e) => {
const customRange = document.getElementById('customDateRange');
if (e.target.value === 'custom') {
customRange.style.display = 'flex';
} else {
customRange.style.display = 'none';
}
});
// Обработчики toggle метрик
document.querySelectorAll('[data-metric]').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
this.toggleMetric(e.target.dataset.metric, e.target.checked);
});
});
}
async initCharts() {
// Основной график
const mainCtx = document.getElementById('mainChart').getContext('2d');
const chartData = await this.loadChartData('main');
this.charts.main = new Chart(mainCtx, {
type: 'line',
data: chartData,
options: {
responsive: true,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.dataset.label || '';
const value = context.parsed.y;
if (label.includes('Выручка')) {
return label + ': ' + new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB'
}).format(value);
}
return label + ': ' + value.toLocaleString('ru-RU');
}
}
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Дата'
}
},
y: {
display: true,
title: {
display: true,
text: 'Значение'
}
}
}
}
});
// График топ товаров
const productsCtx = document.getElementById('topProductsChart').getContext('2d');
const productsData = await this.loadChartData('top-products');
this.charts.topProducts = new Chart(productsCtx, {
type: 'doughnut',
data: productsData,
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
// График источников трафика
const trafficCtx = document.getElementById('trafficSourcesChart').getContext('2d');
const trafficData = await this.loadChartData('traffic-sources');
this.charts.trafficSources = new Chart(trafficCtx, {
type: 'pie',
data: trafficData,
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
// Спарклайны для KPI
this.initSparklines();
}
async initSparklines() {
const kpiCards = document.querySelectorAll('.kpi-card');
for (const card of kpiCards) {
const metric = card.dataset.metric;
const canvas = card.querySelector('canvas');
const ctx = canvas.getContext('2d');
const sparklineData = await this.loadSparklineData(metric);
new Chart(ctx, {
type: 'line',
data: sparklineData,
options: {
responsive: false,
elements: {
point: {
radius: 0
}
},
plugins: {
legend: {
display: false
}
},
scales: {
x: {
display: false
},
y: {
display: false
}
}
}
});
}
}
connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
this.websocket = new WebSocket(`${protocol}//${location.host}/ws/analytics`);
this.websocket.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleRealtimeUpdate(data);
};
this.websocket.onclose = () => {
// Переподключение через 5 секунд
setTimeout(() => this.connectWebSocket(), 5000);
};
}
handleRealtimeUpdate(data) {
switch(data.type) {
case 'kpi_update':
this.updateKPICard(data.metric, data.value);
break;
case 'new_order':
this.addNewOrderToTable(data.order);
this.updateChartData(data);
break;
case 'user_activity':
this.addUserActivity(data.activity);
break;
}
}
updateKPICard(metric, data) {
const card = document.querySelector(`[data-metric="${metric}"]`);
if (card) {
const valueElement = card.querySelector('.kpi-value');
const changeElement = card.querySelector('.kpi-change');
// Анимированное обновление значения
this.animateValue(valueElement, data.formatted_value);
if (changeElement && data.change !== undefined) {
changeElement.className = `kpi-change ${data.change > 0 ? 'positive' : (data.change < 0 ? 'negative' : 'neutral')}`;
changeElement.innerHTML = `
<i class="fas fa-${data.change > 0 ? 'arrow-up' : (data.change < 0 ? 'arrow-down' : 'minus')}"></i>
${Math.abs(data.change).toFixed(1)}%
<span class="change-period">vs ${data.compare_period_name}</span>
`;
}
}
}
animateValue(element, newValue) {
element.classList.add('updating');
setTimeout(() => {
element.innerHTML = newValue;
element.classList.remove('updating');
}, 200);
}
async loadChartData(chartType) {
const response = await fetch(`/api/analytics/chart-data/${chartType}?${this.getFilterParams()}`);
return response.json();
}
async loadSparklineData(metric) {
const response = await fetch(`/api/analytics/sparkline/${metric}?${this.getFilterParams()}`);
return response.json();
}
getFilterParams() {
const period = document.getElementById('periodFilter').value;
const compare = document.getElementById('compareFilter').value;
const dateFrom = document.getElementById('dateFrom').value;
const dateTo = document.getElementById('dateTo').value;
const params = new URLSearchParams({
period: period,
compare: compare
});
if (period === 'custom' && dateFrom && dateTo) {
params.append('date_from', dateFrom);
params.append('date_to', dateTo);
}
return params.toString();
}
async updateDashboard() {
// Показываем индикатор загрузки
this.showLoading();
try {
// Обновляем KPI
const kpiResponse = await fetch(`/api/analytics/kpi?${this.getFilterParams()}`);
const kpiData = await kpiResponse.json();
this.updateKPICards(kpiData);
// Обновляем графики
await this.updateAllCharts();
} catch (error) {
console.error('Failed to update dashboard:', error);
this.showError('Ошибка обновления данных');
} finally {
this.hideLoading();
}
}
startPeriodicUpdates() {
// Обновляем данные каждые 30 секунд
this.updateInterval = setInterval(() => {
this.updateDashboard();
}, 30000);
}
}
// Инициализация dashboard
const dashboard = new AnalyticsDashboard();
// Глобальные функции
function updateDashboard() {
dashboard.updateDashboard();
}
function exportReport() {
const params = dashboard.getFilterParams();
window.open(`/api/analytics/export?${params}`, '_blank');
}
function drillDown(metric) {
window.open(`/analytics/drilldown/${metric}`, '_blank');
}
</script>
{% endblock %}
{% block page_scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="{{ asset('js/dashboard.js') }}"></script>
{% endblock %}
Поздравляем! Вы изучили продвинутые возможности MNRFY Framework. Эти примеры покрывают большинство сложных задач современной веб-разработки: от многоуровневых меню с правами доступа до систем аналитики в реальном времени.
Совет: Используйте эти примеры как основу для создания собственных компонентов. Комбинируйте различные техники для решения уникальных задач вашего проекта.