Files
tourrism_site/public/image-manager.html
Andrey K. Choi 13c752b93a feat: Оптимизация навигации AdminJS в логические группы
- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование
- Удалены дублирующие настройки navigation для чистой группировки
- Добавлены CSS стили для визуального отображения иерархии с отступами
- Добавлены эмодзи-иконки для каждого типа ресурсов через CSS
- Улучшена навигация с правильной вложенностью элементов
2025-11-30 21:57:58 +09:00

953 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>