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

@@ -1,358 +1,362 @@
<!-- Portfolio List -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
<i class="fas fa-briefcase mr-2"></i>
Управление портфолио
</h3>
<p class="mt-1 text-sm text-gray-500">
Всего проектов: <%= portfolio ? portfolio.length : 0 %>
</p>
<!-- Content Header (Page header) -->
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1><i class="fas fa-briefcase mr-2"></i>Управление портфолио</h1>
</div>
<div class="flex space-x-3">
<div class="flex rounded-md shadow-sm">
<input type="text" id="searchInput" placeholder="Поиск проектов..."
class="block w-full rounded-l-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<button type="button" onclick="searchProjects()"
class="relative -ml-px inline-flex items-center px-3 py-2 rounded-r-md border border-l-0 border-gray-300 bg-gray-50 text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">
<i class="fas fa-search"></i>
</button>
</div>
<select id="categoryFilter" onchange="filterByCategory()" class="rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
<option value="">Все категории</option>
<option value="web-development">Веб-разработка</option>
<option value="mobile-app">Мобильное приложение</option>
<option value="ui-ux-design">UI/UX дизайн</option>
<option value="e-commerce">E-commerce</option>
<option value="enterprise">Корпоративное</option>
<option value="other">Другое</option>
</select>
<a href="/admin/portfolio/add" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center">
<i class="fas fa-plus mr-2"></i>
Добавить проект
</a>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="/admin/dashboard">Админ</a></li>
<li class="breadcrumb-item active">Портфолио</li>
</ol>
</div>
</div>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul role="list" class="divide-y divide-gray-200">
<% if (portfolio && portfolio.length > 0) { %>
<% portfolio.forEach(item => { %>
<li class="portfolio-item" data-category="<%= item.category %>" data-title="<%= item.title.toLowerCase() %>">
<div class="px-4 py-4">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<% if (item.images && item.images.length > 0) { %>
<img class="h-16 w-16 rounded-lg object-cover border border-gray-200" src="<%= item.images[0].url %>" alt="<%= item.title %>">
<% } else { %>
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center">
<i class="fas fa-image text-gray-400 text-xl"></i>
</div>
<% } %>
</div>
<!-- Main content -->
<section class="content">
<div class="container-fluid">
<!-- Search and Filter Bar -->
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="input-group">
<input type="text" id="searchInput" class="form-control" placeholder="Поиск проектов...">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" onclick="searchProjects()">
<i class="fas fa-search"></i>
</button>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center">
<h4 class="text-base font-medium text-gray-900 truncate"><%= item.title %></h4>
<div class="ml-3 flex items-center space-x-2">
<% if (item.featured) { %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<i class="fas fa-star mr-1"></i>
Рекомендуемое
</span>
<% } %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= item.isPublished ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' %>">
<i class="fas <%= item.isPublished ? 'fa-check-circle' : 'fa-clock' %> mr-1"></i>
<%= item.isPublished ? 'Опубликовано' : 'Черновик' %>
</span>
</div>
</div>
<p class="mt-1 text-sm text-gray-600 line-clamp-2"><%= item.shortDescription || 'Описание не указано' %></p>
<div class="mt-2 flex items-center text-sm text-gray-500 space-x-4">
<div class="flex items-center">
<i class="fas fa-folder mr-1"></i>
<span class="capitalize"><%= item.category.replace('-', ' ') %></span>
</div>
<div class="flex items-center">
<i class="fas fa-calendar mr-1"></i>
<%= new Date(item.createdAt).toLocaleDateString('ru-RU') %>
</div>
<% if (item.viewCount && item.viewCount > 0) { %>
<div class="flex items-center">
<i class="fas fa-eye mr-1"></i>
<%= item.viewCount %> просмотров
</div>
<% } %>
<% if (item.technologies && item.technologies.length > 0) { %>
<div class="flex items-center">
<i class="fas fa-code mr-1"></i>
<%= item.technologies.slice(0, 2).join(', ') %><%= item.technologies.length > 2 ? '...' : '' %>
</div>
<% } %>
</div>
</div>
</div>
<div class="flex items-center space-x-1 ml-4">
<% if (item.isPublished) { %>
<a href="/portfolio/<%= item.id %>" target="_blank"
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors">
<i class="fas fa-external-link-alt text-sm"></i>
</a>
<% } %>
<button onclick="togglePublish('<%= item.id %>', '<%= item.isPublished %>')"
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-green-600 hover:bg-green-50 transition-colors"
title="<%= item.isPublished ? 'Скрыть' : 'Опубликовать' %>">
<i class="fas <%= item.isPublished ? 'fa-eye-slash' : 'fa-eye' %> text-sm"></i>
</button>
<a href="/admin/portfolio/edit/<%= item.id %>"
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
title="Редактировать">
<i class="fas fa-edit text-sm"></i>
</a>
<button onclick="duplicatePortfolio('<%= item.id %>')"
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-yellow-600 hover:bg-yellow-50 transition-colors"
title="Дублировать">
<i class="fas fa-copy text-sm"></i>
</button>
<button onclick="deletePortfolio('<%= item.id %>', '<%= item.title %>')"
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Удалить">
<i class="fas fa-trash text-sm"></i>
</button>
</div>
</div>
<div class="col-md-3">
<select id="categoryFilter" class="form-control" onchange="filterByCategory()">
<option value="">Все категории</option>
<option value="web-development">Веб-разработка</option>
<option value="mobile-app">Мобильное приложение</option>
<option value="ui-ux-design">UI/UX дизайн</option>
<option value="e-commerce">E-commerce</option>
<option value="enterprise">Корпоративное</option>
<option value="other">Другое</option>
</select>
</div>
<div class="col-md-2">
<select id="statusFilter" class="form-control" onchange="filterByStatus()">
<option value="">Все статусы</option>
<option value="published">Опубликовано</option>
<option value="draft">Черновик</option>
<option value="featured">Рекомендуемое</option>
</select>
</div>
<div class="col-md-3 text-right">
<a href="/admin/portfolio/add" class="btn btn-primary">
<i class="fas fa-plus mr-1"></i> Добавить проект
</a>
</div>
</div>
</li>
<% }) %>
<% } else { %>
<li>
<div class="px-4 py-8 text-center">
<i class="fas fa-briefcase text-4xl text-gray-300 mb-4"></i>
<p class="text-gray-500">Проекты не найдены</p>
<a href="/admin/portfolio/add" class="mt-2 inline-block bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium">
Добавить первый проект
</a>
</div>
</li>
<% } %>
</ul>
</div>
<!-- Pagination -->
<% if (pagination && pagination.total > 1) { %>
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="flex-1 flex justify-between sm:hidden">
<% if (pagination.hasPrev) { %>
<a href="?page=<%= pagination.current - 1 %>" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Предыдущая
</a>
<% } %>
<% if (pagination.hasNext) { %>
<a href="?page=<%= pagination.current + 1 %>" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Следующая
</a>
<% } %>
</div>
</div>
</div>
<% } %>
</div>
<!-- Portfolio Table -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Список проектов</h3>
<div class="card-tools">
<span class="badge badge-secondary" id="projectCount">
Всего: <%= portfolio ? portfolio.length : 0 %>
</span>
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-hover text-nowrap">
<thead>
<tr>
<th style="width: 60px;">Фото</th>
<th>Название</th>
<th>Категория</th>
<th>Статус</th>
<th>Технологии</th>
<th>Создано</th>
<th style="width: 150px;">Действия</th>
</tr>
</thead>
<tbody>
<% if (portfolio && portfolio.length > 0) { %>
<% portfolio.forEach(item => { %>
<tr class="portfolio-item" data-category="<%= item.category %>" data-title="<%= item.title.toLowerCase() %>" data-status="<%= item.isPublished ? 'published' : 'draft' %><%= item.featured ? ' featured' : '' %>">
<td>
<% if (item.images && item.images.length > 0) { %>
<img src="<%= item.images[0].url %>" alt="<%= item.title %>" class="img-circle img-size-32">
<% } else { %>
<div class="img-circle bg-secondary d-flex align-items-center justify-content-center" style="width: 32px; height: 32px;">
<i class="fas fa-image text-white"></i>
</div>
<% } %>
</td>
<td>
<div>
<strong><%= item.title %></strong>
<% if (item.featured) { %>
<span class="badge badge-warning ml-1">
<i class="fas fa-star"></i>
</span>
<% } %>
</div>
<small class="text-muted"><%= item.shortDescription || 'Описание не указано' %></small>
</td>
<td>
<span class="badge badge-info">
<%= item.category.replace('-', ' ') %>
</span>
</td>
<td>
<% if (item.isPublished) { %>
<span class="badge badge-success">
<i class="fas fa-check-circle mr-1"></i>Опубликовано
</span>
<% } else { %>
<span class="badge badge-secondary">
<i class="fas fa-clock mr-1"></i>Черновик
</span>
<% } %>
</td>
<td>
<% if (item.technologies && item.technologies.length > 0) { %>
<% item.technologies.slice(0, 2).forEach(tech => { %>
<span class="badge badge-light mr-1"><%= tech %></span>
<% }) %>
<% if (item.technologies.length > 2) { %>
<span class="text-muted">+<%= item.technologies.length - 2 %></span>
<% } %>
<% } else { %>
<span class="text-muted">—</span>
<% } %>
</td>
<td>
<small class="text-muted">
<%= new Date(item.createdAt).toLocaleDateString('ru-RU') %>
</small>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<% if (item.isPublished) { %>
<a href="/portfolio/<%= item.id %>" target="_blank" class="btn btn-info btn-sm" title="Просмотр">
<i class="fas fa-external-link-alt"></i>
</a>
<% } %>
<button type="button" class="btn btn-<%= item.isPublished ? 'warning' : 'success' %> btn-sm"
onclick="togglePublish('<%= item.id %>', '<%= item.isPublished %>')"
title="<%= item.isPublished ? 'Скрыть' : 'Опубликовать' %>">
<i class="fas <%= item.isPublished ? 'fa-eye-slash' : 'fa-eye' %>"></i>
</button>
<a href="/admin/portfolio/edit/<%= item.id %>" class="btn btn-primary btn-sm" title="Редактировать">
<i class="fas fa-edit"></i>
</a>
<button type="button" class="btn btn-danger btn-sm"
onclick="deletePortfolio('<%= item.id %>', '<%= item.title %>')"
title="Удалить">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
<% }) %>
<% } else { %>
<tr>
<td colspan="7" class="text-center py-4">
<div class="text-muted">
<i class="fas fa-briefcase fa-3x mb-3"></i>
<p>Проекты не найдены</p>
<a href="/admin/portfolio/add" class="btn btn-primary">
Добавить первый проект
</a>
</div>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
<!-- Pagination -->
<% if (pagination && pagination.total > 1) { %>
<div class="card-footer clearfix">
<ul class="pagination pagination-sm m-0 float-right">
<% if (pagination.hasPrev) { %>
<li class="page-item">
<a class="page-link" href="?page=<%= pagination.current - 1 %>">«</a>
</li>
<% } %>
<% for (let i = 1; i <= pagination.total; i++) { %>
<li class="page-item <%= pagination.current === i ? 'active' : '' %>">
<a class="page-link" href="?page=<%= i %>"><%= i %></a>
</li>
<% } %>
<% if (pagination.hasNext) { %>
<li class="page-item">
<a class="page-link" href="?page=<%= pagination.current + 1 %>">»</a>
</li>
<% } %>
</ul>
</div>
<% } %>
</div>
</div>
</section>
<script>
function deletePortfolio(id, title) {
if (confirm(`Вы уверены, что хотите удалить проект "${title}"?\n\nЭто действие нельзя отменить.`)) {
const button = event.target.closest('button');
const originalContent = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin text-sm"></i>';
button.disabled = true;
fetch(`/admin/portfolio/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Проект успешно удален', 'success');
// Плавное удаление элемента
const listItem = button.closest('li');
listItem.style.opacity = '0.5';
listItem.style.transform = 'scale(0.95)';
setTimeout(() => {
listItem.remove();
updateProjectCount();
}, 300);
} else {
showNotification(data.message || 'Ошибка при удалении проекта', 'error');
button.innerHTML = originalContent;
button.disabled = false;
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Ошибка при удалении проекта', 'error');
button.innerHTML = originalContent;
button.disabled = false;
});
}
Swal.fire({
title: 'Удалить проект?',
text: `Вы уверены, что хотите удалить проект "${title}"?`,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: 'Да, удалить!',
cancelButtonText: 'Отмена'
}).then((result) => {
if (result.isConfirmed) {
fetch(`/api/admin/portfolio/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
Swal.fire('Удалено!', 'Проект был удален.', 'success').then(() => {
location.reload();
});
} else {
Swal.fire('Ошибка!', data.message || 'Ошибка при удалении проекта', 'error');
}
})
.catch(error => {
console.error('Error:', error);
Swal.fire('Ошибка!', 'Произошла ошибка при удалении проекта', 'error');
});
}
});
}
function togglePublish(id, currentStatus) {
const button = event.target.closest('button');
const originalContent = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin text-sm"></i>';
button.disabled = true;
const isPublished = currentStatus === 'true';
fetch(`/admin/portfolio/${id}/toggle-publish`, {
fetch(`/api/admin/portfolio/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
},
body: JSON.stringify({
isPublished: !isPublished
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
// Обновляем интерфейс
const listItem = button.closest('li');
const statusSpan = listItem.querySelector('.inline-flex');
const newStatus = data.isPublished;
// Обновляем иконку кнопки
button.innerHTML = `<i class="fas ${newStatus ? 'fa-eye-slash' : 'fa-eye'} text-sm"></i>`;
button.title = newStatus ? 'Скрыть' : 'Опубликовать';
// Обновляем статус
statusSpan.className = `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${newStatus ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`;
statusSpan.innerHTML = `<i class="fas ${newStatus ? 'fa-check-circle' : 'fa-clock'} mr-1"></i>${newStatus ? 'Опубликовано' : 'Черновик'}`;
button.disabled = false;
toastr.success(`Проект ${!isPublished ? 'опубликован' : 'скрыт'}`);
setTimeout(() => {
location.reload();
}, 1000);
} else {
showNotification(data.message || 'Ошибка при изменении статуса', 'error');
button.innerHTML = originalContent;
button.disabled = false;
toastr.error(data.message || 'Ошибка при изменении статуса');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Ошибка при изменении статуса', 'error');
button.innerHTML = originalContent;
button.disabled = false;
toastr.error('Произошла ошибка при изменении статуса');
});
}
function duplicatePortfolio(id) {
if (confirm('Создать копию этого проекта?')) {
const button = event.target.closest('button');
const originalContent = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin text-sm"></i>';
button.disabled = true;
// Здесь можно добавить API для дублирования
showNotification('Функция дублирования будет добавлена позже', 'info');
button.innerHTML = originalContent;
button.disabled = false;
}
}
function searchProjects() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const items = document.querySelectorAll('.portfolio-item');
let visibleCount = 0;
items.forEach(item => {
const title = item.dataset.title;
if (title.includes(searchTerm)) {
item.style.display = 'block';
item.style.display = 'table-row';
visibleCount++;
} else {
item.style.display = 'none';
}
});
updateProjectCount();
updateProjectCount(visibleCount, items.length);
}
function filterByCategory() {
const selectedCategory = document.getElementById('categoryFilter').value;
const selectedStatus = document.getElementById('statusFilter').value;
filterPortfolio(selectedCategory, selectedStatus);
}
function filterByStatus() {
const selectedCategory = document.getElementById('categoryFilter').value;
const selectedStatus = document.getElementById('statusFilter').value;
filterPortfolio(selectedCategory, selectedStatus);
}
function filterPortfolio(category, status) {
const items = document.querySelectorAll('.portfolio-item');
let visibleCount = 0;
items.forEach(item => {
const category = item.dataset.category;
if (!selectedCategory || category === selectedCategory) {
item.style.display = 'block';
const itemCategory = item.dataset.category;
const itemStatus = item.dataset.status;
let showItem = true;
if (category && itemCategory !== category) {
showItem = false;
}
if (status && !itemStatus.includes(status)) {
showItem = false;
}
if (showItem) {
item.style.display = 'table-row';
visibleCount++;
} else {
item.style.display = 'none';
}
});
updateProjectCount();
updateProjectCount(visibleCount, items.length);
}
function updateProjectCount() {
const visibleItems = document.querySelectorAll('.portfolio-item[style="display: block"], .portfolio-item:not([style*="display: none"])').length;
const totalItems = document.querySelectorAll('.portfolio-item').length;
const countElement = document.querySelector('h3 + p');
function updateProjectCount(visible, total) {
const countElement = document.getElementById('projectCount');
if (countElement) {
countElement.textContent = `Показано проектов: ${visibleItems} из ${totalItems}`;
if (visible !== undefined) {
countElement.textContent = `Показано: ${visible} из ${total}`;
} else {
countElement.textContent = `Всего: ${total}`;
}
}
}
function showNotification(message, type = 'info') {
// Создаем уведомление
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-md shadow-lg text-white max-w-sm transform transition-all duration-300 translate-x-full`;
switch(type) {
case 'success':
notification.classList.add('bg-green-600');
break;
case 'error':
notification.classList.add('bg-red-600');
break;
case 'info':
notification.classList.add('bg-blue-600');
break;
default:
notification.classList.add('bg-gray-600');
}
notification.innerHTML = `
<div class="flex items-center">
<i class="fas ${type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'} mr-2"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(notification);
// Показываем уведомление
setTimeout(() => {
notification.classList.remove('translate-x-full');
}, 100);
// Скрываем уведомление через 3 секунды
setTimeout(() => {
notification.classList.add('translate-x-full');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
// Обработчик для поиска по Enter
// Search on Enter key
document.getElementById('searchInput').addEventListener('keyup', function(event) {
if (event.key === 'Enter') {
searchProjects();
}
});
// Инициализация
document.addEventListener('DOMContentLoaded', function() {
updateProjectCount();
// Auto search on input
document.getElementById('searchInput').addEventListener('input', function() {
searchProjects();
});
</script>