feat: Оптимизация навигации AdminJS в логические группы

- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование
- Удалены дублирующие настройки navigation для чистой группировки
- Добавлены CSS стили для визуального отображения иерархии с отступами
- Добавлены эмодзи-иконки для каждого типа ресурсов через CSS
- Улучшена навигация с правильной вложенностью элементов
This commit is contained in:
2025-11-30 21:57:58 +09:00
parent 1e7d7c06eb
commit 13c752b93a
47 changed files with 14148 additions and 61 deletions

953
public/image-manager.html Normal file
View File

@@ -0,0 +1,953 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Менеджер изображений</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8f9fa;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.header h1 {
color: #333;
font-size: 24px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: #f8f9fa;
color: #6c757d;
border: 1px solid #dee2e6;
}
.btn-secondary:hover {
background: #e9ecef;
}
/* Загрузка файлов */
.upload-area {
background: white;
border: 2px dashed #ddd;
border-radius: 12px;
padding: 40px;
text-align: center;
margin-bottom: 30px;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover {
border-color: #667eea;
background: #f8f9ff;
}
.upload-area.dragover {
border-color: #667eea;
background: #f0f4ff;
transform: scale(1.02);
}
.upload-icon {
font-size: 48px;
color: #ddd;
margin-bottom: 16px;
}
.upload-area:hover .upload-icon {
color: #667eea;
}
.upload-text {
font-size: 18px;
font-weight: 500;
margin-bottom: 8px;
}
.upload-subtext {
color: #6c757d;
font-size: 14px;
}
/* Фильтры и поиск */
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
flex-wrap: wrap;
gap: 15px;
}
.search-box {
flex: 1;
min-width: 250px;
position: relative;
}
.search-input {
width: 100%;
padding: 12px 40px 12px 15px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.search-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
}
.filter-group {
display: flex;
gap: 10px;
align-items: center;
}
.filter-select {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
font-size: 14px;
}
.view-toggle {
display: flex;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.view-btn {
padding: 8px 12px;
border: none;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.view-btn.active {
background: #667eea;
color: white;
}
/* Сетка изображений */
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.images-list {
display: none;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.image-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
transition: all 0.3s;
cursor: pointer;
}
.image-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.image-preview {
width: 100%;
height: 150px;
background-size: cover;
background-position: center;
background-color: #f8f9fa;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: cover;
}
.image-placeholder {
color: #ddd;
font-size: 48px;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
opacity: 0;
transition: opacity 0.3s;
}
.image-card:hover .image-overlay {
opacity: 1;
}
.overlay-btn {
padding: 8px;
border: none;
border-radius: 6px;
background: rgba(255,255,255,0.2);
color: white;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
}
.overlay-btn:hover {
background: rgba(255,255,255,0.3);
transform: scale(1.1);
}
.image-info {
padding: 15px;
}
.image-name {
font-weight: 500;
margin-bottom: 5px;
word-break: break-word;
}
.image-details {
font-size: 12px;
color: #6c757d;
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.image-url {
font-size: 11px;
color: #6c757d;
background: #f8f9fa;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
word-break: break-all;
}
/* Модальные окна */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
}
.modal.show {
opacity: 1;
visibility: visible;
}
.modal-content {
background: white;
border-radius: 12px;
max-width: 90%;
max-height: 90%;
overflow: auto;
position: relative;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6c757d;
}
.modal-body {
padding: 20px;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
}
/* Прогресс загрузки */
.upload-progress {
margin-top: 20px;
}
.progress-bar {
width: 100%;
height: 6px;
background: #e9ecef;
border-radius: 3px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 3px;
transition: width 0.3s;
width: 0%;
}
.progress-text {
font-size: 14px;
color: #6c757d;
text-align: center;
}
/* Уведомления */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 8px;
color: white;
font-weight: 500;
z-index: 1001;
transform: translateX(100%);
transition: transform 0.3s;
max-width: 400px;
}
.notification.show {
transform: translateX(0);
}
.notification.success {
background: #28a745;
}
.notification.error {
background: #dc3545;
}
.notification.info {
background: #17a2b8;
}
/* Скелетон загрузки */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.skeleton-image {
width: 100%;
height: 150px;
}
.skeleton-text {
height: 16px;
margin: 15px;
border-radius: 4px;
}
.skeleton-text.short {
width: 60%;
}
/* Адаптив */
@media (max-width: 768px) {
.container {
padding: 15px;
}
.header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.filter-group {
justify-content: center;
}
.images-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Заголовок -->
<div class="header">
<h1>🖼️ Менеджер изображений</h1>
<div class="header-actions">
<button class="btn btn-secondary" onclick="refreshGallery()">
🔄 Обновить
</button>
<button class="btn btn-primary" onclick="openUpload()">
📤 Загрузить изображения
</button>
</div>
</div>
<!-- Область загрузки -->
<div class="upload-area" id="uploadArea" onclick="openUpload()">
<div class="upload-icon">📸</div>
<div class="upload-text">Перетащите изображения сюда или нажмите для выбора</div>
<div class="upload-subtext">Поддерживаются JPG, PNG, GIF, WEBP до 10MB</div>
<input type="file" id="fileInput" multiple accept="image/*" style="display: none;" onchange="handleFileUpload(this.files)">
</div>
<!-- Прогресс загрузки -->
<div class="upload-progress" id="uploadProgress" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">Загрузка...</div>
</div>
<!-- Контролы -->
<div class="controls">
<div class="search-box">
<input type="text" class="search-input" placeholder="Поиск по названию или типу..."
oninput="searchImages(this.value)">
<span class="search-icon">🔍</span>
</div>
<div class="filter-group">
<select class="filter-select" onchange="filterByType(this.value)">
<option value="">Все типы</option>
<option value="routes">Маршруты</option>
<option value="guides">Гиды</option>
<option value="articles">Статьи</option>
<option value="general">Общие</option>
</select>
<div class="view-toggle">
<button class="view-btn active" onclick="setView('grid')" data-view="grid"></button>
<button class="view-btn" onclick="setView('list')" data-view="list"></button>
</div>
</div>
</div>
<!-- Сетка изображений -->
<div class="images-grid" id="imagesGrid">
<!-- Скелетоны загрузки -->
<div class="skeleton-card">
<div class="skeleton skeleton-image"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text short"></div>
</div>
<div class="skeleton-card">
<div class="skeleton skeleton-image"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text short"></div>
</div>
<div class="skeleton-card">
<div class="skeleton skeleton-image"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text short"></div>
</div>
</div>
<!-- Список изображений (альтернативный вид) -->
<div class="images-list" id="imagesList">
<!-- Будет заполнено динамически -->
</div>
</div>
<!-- Модальное окно просмотра -->
<div class="modal" id="viewModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Просмотр изображения</h3>
<button class="close-btn" onclick="closeModal('viewModal')">&times;</button>
</div>
<div class="modal-body">
<img class="preview-image" id="previewImage" src="" alt="">
<div style="margin-top: 20px;">
<p><strong>Имя файла:</strong> <span id="fileName"></span></p>
<p><strong>Размер:</strong> <span id="fileSize"></span></p>
<p><strong>URL:</strong> <span id="fileUrl" style="font-family: monospace; background: #f8f9fa; padding: 2px 6px; border-radius: 4px;"></span></p>
</div>
<div style="margin-top: 20px; text-align: center;">
<button class="btn btn-primary" onclick="copyToClipboard()">📋 Копировать URL</button>
<button class="btn btn-secondary" onclick="downloadImage()">💾 Скачать</button>
</div>
</div>
</div>
</div>
<!-- Уведомления -->
<div id="notification" class="notification"></div>
<script>
let currentImages = [];
let filteredImages = [];
let currentView = 'grid';
let currentImage = null;
// Инициализация
document.addEventListener('DOMContentLoaded', function() {
loadImages();
setupDragAndDrop();
});
// Настройка Drag & Drop
function setupDragAndDrop() {
const uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', function(e) {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', function(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
handleFileUpload(e.dataTransfer.files);
});
}
// Загрузка изображений
async function loadImages() {
try {
const response = await fetch('/api/images/gallery');
const data = await response.json();
if (data.success) {
currentImages = data.images || [];
filteredImages = [...currentImages];
renderImages();
} else {
throw new Error(data.error || 'Ошибка загрузки изображений');
}
} catch (error) {
console.error('Ошибка загрузки изображений:', error);
showNotification('Ошибка загрузки изображений', 'error');
currentImages = [];
filteredImages = [];
renderEmptyState();
}
}
// Отображение изображений
function renderImages() {
const grid = document.getElementById('imagesGrid');
const list = document.getElementById('imagesList');
if (filteredImages.length === 0) {
renderEmptyState();
return;
}
const gridHTML = filteredImages.map(image => createImageCard(image)).join('');
grid.innerHTML = gridHTML;
// Список пока не реализован
list.innerHTML = '';
}
// Создание карточки изображения
function createImageCard(image) {
const fileName = image.path.split('/').pop();
const fileExtension = fileName.split('.').pop().toUpperCase();
const fileSize = image.size ? formatFileSize(image.size) : 'Неизвестно';
return `
<div class="image-card" onclick="viewImage('${image.path}', '${fileName}', '${fileSize}')">
<div class="image-preview" style="background-image: url('${image.path}')">
<div class="image-overlay">
<button class="overlay-btn" onclick="event.stopPropagation(); viewImage('${image.path}', '${fileName}', '${fileSize}')" title="Просмотр">👁️</button>
<button class="overlay-btn" onclick="event.stopPropagation(); copyToClipboard('${image.path}')" title="Копировать URL">📋</button>
<button class="overlay-btn" onclick="event.stopPropagation(); deleteImage('${image.path}')" title="Удалить">🗑️</button>
</div>
</div>
<div class="image-info">
<div class="image-name">${fileName}</div>
<div class="image-details">
<span>${fileExtension}</span>
<span>${fileSize}</span>
</div>
<div class="image-url">${image.path}</div>
</div>
</div>
`;
}
// Пустое состояние
function renderEmptyState() {
const grid = document.getElementById('imagesGrid');
grid.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; color: #6c757d;">
<div style="font-size: 64px; margin-bottom: 20px;">📷</div>
<h3>Изображения не найдены</h3>
<p>Загрузите первые изображения или проверьте фильтры поиска</p>
</div>
`;
}
// Открытие диалога загрузки
function openUpload() {
document.getElementById('fileInput').click();
}
// Обработка загрузки файлов
async function handleFileUpload(files) {
if (!files.length) return;
const uploadProgress = document.getElementById('uploadProgress');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
uploadProgress.style.display = 'block';
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Проверка типа файла
if (!file.type.startsWith('image/')) {
showNotification(\`Файл \${file.name} не является изображением\`, 'error');
continue;
}
// Проверка размера файла (10MB)
if (file.size > 10 * 1024 * 1024) {
showNotification(\`Файл \${file.name} слишком большой (максимум 10MB)\`, 'error');
continue;
}
try {
const progress = ((i + 1) / files.length) * 100;
progressFill.style.width = progress + '%';
progressText.textContent = \`Загрузка \${i + 1} из \${files.length}: \${file.name}\`;
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showNotification(\`Изображение \${file.name} загружено успешно\`, 'success');
} else {
throw new Error(result.error || 'Ошибка загрузки');
}
} catch (error) {
console.error('Ошибка загрузки:', error);
showNotification(\`Ошибка загрузки \${file.name}\`, 'error');
}
}
uploadProgress.style.display = 'none';
await loadImages(); // Перезагружаем галерею
}
// Поиск изображений
function searchImages(query) {
if (!query.trim()) {
filteredImages = [...currentImages];
} else {
const searchTerm = query.toLowerCase();
filteredImages = currentImages.filter(image => {
const fileName = image.path.toLowerCase();
return fileName.includes(searchTerm);
});
}
renderImages();
}
// Фильтрация по типу
function filterByType(type) {
if (!type) {
filteredImages = [...currentImages];
} else {
filteredImages = currentImages.filter(image => {
return image.path.includes(\`/uploads/\${type}/\`) ||
image.path.includes(\`/\${type}/\`);
});
}
renderImages();
}
// Переключение вида
function setView(view) {
currentView = view;
// Обновляем кнопки
document.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(\`[data-view="\${view}"]\`).classList.add('active');
// Переключаем отображение
const grid = document.getElementById('imagesGrid');
const list = document.getElementById('imagesList');
if (view === 'grid') {
grid.style.display = 'grid';
list.style.display = 'none';
} else {
grid.style.display = 'none';
list.style.display = 'block';
}
}
// Просмотр изображения
function viewImage(path, name, size) {
currentImage = { path, name, size };
document.getElementById('previewImage').src = path;
document.getElementById('fileName').textContent = name;
document.getElementById('fileSize').textContent = size;
document.getElementById('fileUrl').textContent = path;
showModal('viewModal');
}
// Копирование URL в буфер обмена
function copyToClipboard(url = null) {
const textToCopy = url || currentImage?.path || document.getElementById('fileUrl').textContent;
navigator.clipboard.writeText(textToCopy).then(() => {
showNotification('URL скопирован в буфер обмена', 'success');
}).catch(() => {
// Fallback для старых браузеров
const textArea = document.createElement('textarea');
textArea.value = textToCopy;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showNotification('URL скопирован в буфер обмена', 'success');
});
}
// Скачивание изображения
function downloadImage() {
if (currentImage) {
const link = document.createElement('a');
link.href = currentImage.path;
link.download = currentImage.name;
link.click();
}
}
// Удаление изображения
async function deleteImage(path) {
if (!confirm('Вы уверены, что хотите удалить это изображение?')) {
return;
}
try {
const response = await fetch('/api/images/delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ path })
});
const result = await response.json();
if (result.success) {
showNotification('Изображение удалено', 'success');
await loadImages();
} else {
throw new Error(result.error || 'Ошибка удаления');
}
} catch (error) {
console.error('Ошибка удаления:', error);
showNotification('Ошибка удаления изображения', 'error');
}
}
// Обновление галереи
async function refreshGallery() {
showNotification('Обновление галереи...', 'info');
await loadImages();
}
// Показ модального окна
function showModal(modalId) {
document.getElementById(modalId).classList.add('show');
}
// Закрытие модального окна
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('show');
}
// Показ уведомлений
function showNotification(message, type = 'info') {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = \`notification \${type} show\`;
setTimeout(() => {
notification.classList.remove('show');
}, 3000);
}
// Форматирование размера файла
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Закрытие модального окна по клику на overlay
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal')) {
e.target.classList.remove('show');
}
});
// Горячие клавиши
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal.show').forEach(modal => {
modal.classList.remove('show');
});
}
});
</script>
</body>
</html>