AdminLTE3

This commit is contained in:
2025-10-26 22:14:47 +09:00
parent 291fc63a4c
commit 9974811a3e
226 changed files with 88284 additions and 3406 deletions

View File

@@ -0,0 +1,788 @@
<!-- Content Header (Page header) -->
<section class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1><i class="fas fa-images mr-2"></i>Медиа Галерея</h1>
</div>
<div class="col-sm-6">
<div class="float-sm-right">
<button id="refresh-btn" class="btn btn-secondary">
<i class="fas fa-sync-alt mr-1"></i>Обновить
</button>
<button id="upload-btn" class="btn btn-primary">
<i class="fas fa-upload mr-1"></i>Загрузить файлы
</button>
</div>
</div>
</div>
</div>
</section>
<!-- Main content -->
<section class="content">
<div class="container-fluid">
<!-- Upload Zone -->
<div id="upload-zone" class="card" style="display: none;">
<div class="card-body text-center">
<div class="mb-4">
<i class="fas fa-cloud-upload-alt fa-6x text-muted mb-4"></i>
<p class="h5 text-muted mb-2">Перетащите файлы сюда или нажмите для выбора</p>
<p class="text-muted">Поддерживаются: JPG, PNG, GIF, SVG (максимум 10MB каждый)</p>
</div>
<input type="file" id="file-input" multiple accept="image/*" class="d-none">
<button type="button" onclick="document.getElementById('file-input').click()" class="btn btn-primary">
Выбрать файлы
</button>
<button id="cancel-upload" class="btn btn-secondary ml-3">
Отмена
</button>
</div>
</div>
<!-- Upload Progress -->
<div id="upload-progress" class="card" style="display: none;">
<div class="card-header">
<h3 class="card-title">Загрузка файлов</h3>
</div>
<div class="card-body">
<div id="progress-list">
<!-- Progress items will be added here -->
</div>
</div>
</div>
<!-- Filter and Search -->
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="form-group">
<label>Тип файла</label>
<select id="file-type-filter" class="form-control">
<option value="">Все типы</option>
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="image/gif">GIF</option>
<option value="image/svg+xml">SVG</option>
</select>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label>Размер</label>
<select id="size-filter" class="form-control">
<option value="">Любой размер</option>
<option value="small">Маленький (&lt; 1MB)</option>
<option value="medium">Средний (1-5MB)</option>
<option value="large">Большой (&gt; 5MB)</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Поиск</label>
<div class="input-group">
<input type="text" id="search-input" placeholder="Поиск по имени файла..." class="form-control">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-search"></i></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Media Grid -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Файлы</h3>
<div class="card-tools">
<span id="file-count" class="badge badge-secondary">Загрузка...</span>
<div class="btn-group ml-2">
<button id="grid-view" class="btn btn-sm btn-default">
<i class="fas fa-th-large"></i>
</button>
<button id="list-view" class="btn btn-sm btn-default">
<i class="fas fa-list"></i>
</button>
</div>
</div>
</div>
<div class="card-body">
<!-- Loading State -->
<div id="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Загрузка...</span>
</div>
<p class="mt-2 text-muted">Загрузка медиа файлов...</p>
</div>
<!-- Empty State -->
<div id="empty-state" class="text-center py-5" style="display: none;">
<i class="fas fa-images fa-6x text-muted mb-4"></i>
<h4 class="text-muted mb-2">Нет загруженных файлов</h4>
<p class="text-muted mb-4">Начните с загрузки ваших первых изображений</p>
<button onclick="document.getElementById('upload-btn').click()" class="btn btn-primary">
<i class="fas fa-upload mr-2"></i>Загрузить файлы
</button>
</div>
<!-- Media Grid -->
<div id="media-grid" class="row">
<!-- Media items will be loaded here -->
</div>
<!-- Media List -->
<div id="media-list" style="display: none;">
<!-- List items will be loaded here -->
</div>
</div>
<!-- Pagination -->
<div class="card-footer" id="pagination" style="display: none;">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center m-0">
<li class="page-item">
<button id="prev-page" class="page-link">
<i class="fas fa-chevron-left"></i>
</button>
</li>
<div id="page-numbers" class="d-flex">
<!-- Page numbers will be added here -->
</div>
<li class="page-item">
<button id="next-page" class="page-link">
<i class="fas fa-chevron-right"></i>
</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</section>
<!-- Media Preview Modal -->
<div class="modal fade" id="preview-modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 id="modal-title" class="modal-title">Предпросмотр файла</h4>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-8">
<img id="modal-image" src="" alt="" class="img-fluid rounded">
</div>
<div class="col-md-4">
<div class="form-group">
<label>Имя файла</label>
<input id="modal-filename" type="text" class="form-control" readonly>
</div>
<div class="form-group">
<label>URL</label>
<div class="input-group">
<input id="modal-url" type="text" class="form-control" readonly>
<div class="input-group-append">
<button onclick="copyToClipboard()" class="btn btn-outline-secondary" type="button">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="form-group">
<label>Размер</label>
<p id="modal-size" class="form-control-plaintext">-</p>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label>Тип</label>
<p id="modal-type" class="form-control-plaintext">-</p>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="form-group">
<label>Ширина</label>
<p id="modal-width" class="form-control-plaintext">-</p>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label>Высота</label>
<p id="modal-height" class="form-control-plaintext">-</p>
</div>
</div>
</div>
<div class="form-group">
<label>Загружено</label>
<p id="modal-date" class="form-control-plaintext">-</p>
</div>
<div class="btn-group d-flex">
<button onclick="downloadFile()" class="btn btn-primary">
<i class="fas fa-download mr-1"></i>Скачать
</button>
<button onclick="deleteFile()" class="btn btn-danger">
<i class="fas fa-trash mr-1"></i>Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
class MediaGallery {
constructor() {
this.currentFiles = [];
this.filteredFiles = [];
this.currentView = 'grid';
this.currentPage = 1;
this.itemsPerPage = 24;
this.currentFile = null;
this.init();
}
init() {
this.setupEventListeners();
this.loadMedia();
}
setupEventListeners() {
// Upload button
document.getElementById('upload-btn').addEventListener('click', () => {
this.showUploadZone();
});
// Cancel upload
document.getElementById('cancel-upload').addEventListener('click', () => {
this.hideUploadZone();
});
// File input
document.getElementById('file-input').addEventListener('change', (e) => {
this.handleFiles(e.target.files);
});
// Refresh button
document.getElementById('refresh-btn').addEventListener('click', () => {
this.loadMedia();
});
// View toggle
document.getElementById('grid-view').addEventListener('click', () => {
this.setView('grid');
});
document.getElementById('list-view').addEventListener('click', () => {
this.setView('list');
});
// Filters
document.getElementById('file-type-filter').addEventListener('change', () => {
this.applyFilters();
});
document.getElementById('size-filter').addEventListener('change', () => {
this.applyFilters();
});
document.getElementById('search-input').addEventListener('input', () => {
this.applyFilters();
});
// Modal
document.getElementById('close-modal').addEventListener('click', () => {
this.closeModal();
});
// Upload zone drag and drop
const uploadZone = document.getElementById('upload-zone');
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('border-blue-500', 'bg-blue-50');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('border-blue-500', 'bg-blue-50');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('border-blue-500', 'bg-blue-50');
this.handleFiles(e.dataTransfer.files);
});
}
async loadMedia() {
try {
document.getElementById('loading').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
document.getElementById('media-grid').style.display = 'none';
const response = await fetch('/api/media/list');
const data = await response.json();
if (data.success) {
this.currentFiles = data.images || [];
this.applyFilters();
} else {
throw new Error(data.message || 'Failed to load media');
}
} catch (error) {
console.error('Error loading media:', error);
this.showError('Ошибка загрузки медиа файлов');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
applyFilters() {
const typeFilter = document.getElementById('file-type-filter').value;
const sizeFilter = document.getElementById('size-filter').value;
const searchQuery = document.getElementById('search-input').value.toLowerCase();
this.filteredFiles = this.currentFiles.filter(file => {
// Type filter
if (typeFilter && file.mimetype !== typeFilter) {
return false;
}
// Size filter
if (sizeFilter) {
const sizeInMB = file.size / (1024 * 1024);
if (sizeFilter === 'small' && sizeInMB >= 1) return false;
if (sizeFilter === 'medium' && (sizeInMB < 1 || sizeInMB > 5)) return false;
if (sizeFilter === 'large' && sizeInMB <= 5) return false;
}
// Search filter
if (searchQuery && !file.filename.toLowerCase().includes(searchQuery)) {
return false;
}
return true;
});
this.updateFileCount();
this.renderMedia();
}
updateFileCount() {
const total = this.currentFiles.length;
const filtered = this.filteredFiles.length;
const countText = filtered === total ?
`${total} файлов` :
`${filtered} из ${total} файлов`;
document.getElementById('file-count').textContent = countText;
}
renderMedia() {
if (this.filteredFiles.length === 0) {
document.getElementById('empty-state').style.display = 'block';
document.getElementById('media-grid').style.display = 'none';
document.getElementById('media-list').style.display = 'none';
document.getElementById('pagination').style.display = 'none';
return;
}
document.getElementById('empty-state').style.display = 'none';
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
const pageFiles = this.filteredFiles.slice(startIndex, endIndex);
if (this.currentView === 'grid') {
this.renderGrid(pageFiles);
} else {
this.renderList(pageFiles);
}
this.updatePagination();
}
renderGrid(files) {
document.getElementById('media-grid').style.display = 'grid';
document.getElementById('media-list').style.display = 'none';
const grid = document.getElementById('media-grid');
grid.innerHTML = files.map(file => `
<div class="group relative bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow"
onclick="mediaGallery.openModal('${file.filename}')">
<div class="aspect-square">
<img src="${file.url}" alt="${file.filename}"
class="w-full h-full object-cover">
</div>
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-25 transition-opacity flex items-center justify-center">
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<button class="bg-white bg-opacity-90 text-gray-800 px-3 py-2 rounded-lg mr-2"
onclick="event.stopPropagation(); mediaGallery.downloadFile('${file.filename}')">
<i class="fas fa-download"></i>
</button>
<button class="bg-red-500 bg-opacity-90 text-white px-3 py-2 rounded-lg"
onclick="event.stopPropagation(); mediaGallery.deleteFile('${file.filename}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white p-2">
<p class="text-xs truncate">${file.filename}</p>
<p class="text-xs text-gray-300">${this.formatFileSize(file.size)}</p>
</div>
</div>
`).join('');
}
renderList(files) {
document.getElementById('media-grid').style.display = 'none';
document.getElementById('media-list').style.display = 'block';
const list = document.getElementById('media-list');
list.innerHTML = files.map(file => `
<div class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer"
onclick="mediaGallery.openModal('${file.filename}')">
<div class="w-16 h-16 flex-shrink-0 mr-4">
<img src="${file.url}" alt="${file.filename}"
class="w-full h-full object-cover rounded">
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate">${file.filename}</h4>
<p class="text-sm text-gray-500">${this.formatFileSize(file.size)} • ${file.mimetype}</p>
<p class="text-xs text-gray-400">${new Date(file.uploadedAt).toLocaleDateString('ru-RU')}</p>
</div>
<div class="flex space-x-2">
<button class="text-blue-600 hover:text-blue-800 p-2"
onclick="event.stopPropagation(); mediaGallery.downloadFile('${file.filename}')">
<i class="fas fa-download"></i>
</button>
<button class="text-red-600 hover:text-red-800 p-2"
onclick="event.stopPropagation(); mediaGallery.deleteFile('${file.filename}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`).join('');
}
setView(view) {
this.currentView = view;
// Update button states
document.getElementById('grid-view').classList.toggle('bg-blue-600', view === 'grid');
document.getElementById('grid-view').classList.toggle('text-white', view === 'grid');
document.getElementById('list-view').classList.toggle('bg-blue-600', view === 'list');
document.getElementById('list-view').classList.toggle('text-white', view === 'list');
this.renderMedia();
}
showUploadZone() {
document.getElementById('upload-zone').style.display = 'block';
}
hideUploadZone() {
document.getElementById('upload-zone').style.display = 'none';
document.getElementById('file-input').value = '';
}
async handleFiles(files) {
const validFiles = Array.from(files).filter(file => {
if (!file.type.startsWith('image/')) {
this.showError(`${file.name} не является изображением`);
return false;
}
if (file.size > 10 * 1024 * 1024) {
this.showError(`${file.name} слишком большой (максимум 10MB)`);
return false;
}
return true;
});
if (validFiles.length === 0) return;
this.hideUploadZone();
await this.uploadFiles(validFiles);
}
async uploadFiles(files) {
const progressContainer = document.getElementById('upload-progress');
const progressList = document.getElementById('progress-list');
progressContainer.style.display = 'block';
progressList.innerHTML = '';
for (const file of files) {
const progressItem = this.createProgressItem(file);
progressList.appendChild(progressItem);
try {
await this.uploadSingleFile(file, progressItem);
} catch (error) {
this.updateProgressItem(progressItem, 'error', error.message);
}
}
setTimeout(() => {
progressContainer.style.display = 'none';
this.loadMedia();
}, 2000);
}
createProgressItem(file) {
const div = document.createElement('div');
div.className = 'flex items-center justify-between p-3 bg-gray-50 rounded';
div.innerHTML = `
<div class="flex items-center space-x-3">
<i class="fas fa-image text-gray-400"></i>
<span class="text-sm text-gray-900">${file.name}</span>
<span class="text-xs text-gray-500">${this.formatFileSize(file.size)}</span>
</div>
<div class="flex items-center space-x-3">
<div class="w-32 bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full progress-bar" style="width: 0%"></div>
</div>
<span class="text-sm text-gray-600 status">0%</span>
</div>
`;
return div;
}
updateProgressItem(item, status, message = '') {
const statusElement = item.querySelector('.status');
const progressBar = item.querySelector('.progress-bar');
if (status === 'error') {
statusElement.textContent = 'Ошибка';
statusElement.className = 'text-sm text-red-600 status';
progressBar.className = 'bg-red-600 h-2 rounded-full progress-bar';
progressBar.style.width = '100%';
} else if (status === 'success') {
statusElement.textContent = 'Готово';
statusElement.className = 'text-sm text-green-600 status';
progressBar.className = 'bg-green-600 h-2 rounded-full progress-bar';
progressBar.style.width = '100%';
}
}
async uploadSingleFile(file, progressItem) {
const formData = new FormData();
formData.append('images', file);
const xhr = new XMLHttpRequest();
const progressBar = progressItem.querySelector('.progress-bar');
const status = progressItem.querySelector('.status');
return new Promise((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
progressBar.style.width = percentComplete + '%';
status.textContent = Math.round(percentComplete) + '%';
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.success) {
this.updateProgressItem(progressItem, 'success');
resolve();
} else {
reject(new Error(response.message));
}
} else {
reject(new Error('Upload failed'));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Network error'));
});
xhr.open('POST', '/api/media/upload-multiple');
xhr.send(formData);
});
}
openModal(filename) {
const file = this.currentFiles.find(f => f.filename === filename);
if (!file) return;
this.currentFile = file;
document.getElementById('modal-title').textContent = file.filename;
document.getElementById('modal-image').src = file.url;
document.getElementById('modal-filename').value = file.filename;
document.getElementById('modal-url').value = window.location.origin + file.url;
document.getElementById('modal-size').textContent = this.formatFileSize(file.size);
document.getElementById('modal-type').textContent = file.mimetype;
document.getElementById('modal-date').textContent = new Date(file.uploadedAt).toLocaleDateString('ru-RU');
// Load image to get dimensions
const img = new Image();
img.onload = () => {
document.getElementById('modal-width').textContent = img.width + 'px';
document.getElementById('modal-height').textContent = img.height + 'px';
};
img.src = file.url;
document.getElementById('preview-modal').style.display = 'flex';
}
closeModal() {
document.getElementById('preview-modal').style.display = 'none';
this.currentFile = null;
}
async deleteFile(filename) {
if (!confirm(`Вы уверены, что хотите удалить файл "${filename}"?`)) {
return;
}
try {
const response = await fetch(`/api/media/${filename}`, {
method: 'DELETE'
});
if (response.ok) {
this.showSuccess('Файл удален');
this.loadMedia();
if (this.currentFile && this.currentFile.filename === filename) {
this.closeModal();
}
} else {
throw new Error('Failed to delete file');
}
} catch (error) {
console.error('Error deleting file:', error);
this.showError('Ошибка удаления файла');
}
}
downloadFile(filename) {
const file = filename ?
this.currentFiles.find(f => f.filename === filename) :
this.currentFile;
if (!file) return;
const link = document.createElement('a');
link.href = file.url;
link.download = file.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
updatePagination() {
const totalPages = Math.ceil(this.filteredFiles.length / this.itemsPerPage);
if (totalPages <= 1) {
document.getElementById('pagination').style.display = 'none';
return;
}
document.getElementById('pagination').style.display = 'block';
// Update prev/next buttons
document.getElementById('prev-page').disabled = this.currentPage === 1;
document.getElementById('next-page').disabled = this.currentPage === totalPages;
// Update page numbers
const pageNumbers = document.getElementById('page-numbers');
pageNumbers.innerHTML = '';
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
const button = document.createElement('button');
button.className = `px-3 py-2 rounded ${
i === this.currentPage ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'
}`;
button.textContent = i;
button.onclick = () => this.goToPage(i);
pageNumbers.appendChild(button);
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
const span = document.createElement('span');
span.className = 'px-2 py-2 text-gray-400';
span.textContent = '...';
pageNumbers.appendChild(span);
}
}
}
goToPage(page) {
this.currentPage = page;
this.renderMedia();
}
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
showError(message) {
this.showNotification(message, 'error');
}
showSuccess(message) {
this.showNotification(message, 'success');
}
showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' : 'bg-blue-500'
}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
}
// Global functions for modal
function copyToClipboard() {
const urlInput = document.getElementById('modal-url');
urlInput.select();
document.execCommand('copy');
mediaGallery.showSuccess('URL скопирован в буфер обмена');
}
function downloadFile() {
mediaGallery.downloadFile();
}
function deleteFile() {
if (mediaGallery.currentFile) {
mediaGallery.deleteFile(mediaGallery.currentFile.filename);
}
}
// Initialize
let mediaGallery;
document.addEventListener('DOMContentLoaded', () => {
mediaGallery = new MediaGallery();
});
</script>
</body>
</html>