feat: Реализован полный CRUD для админ-панели и улучшена функциональность
- Portfolio CRUD: добавление, редактирование, удаление, переключение публикации - Services CRUD: полное управление услугами с возможностью активации/деактивации - Banner system: новая модель Banner с CRUD операциями и аналитикой кликов - Telegram integration: расширенные настройки бота, обнаружение чатов, отправка сообщений - Media management: улучшенная загрузка файлов с оптимизацией изображений и превью - UI improvements: обновлённые админ-панели с rich-text редактором и drag&drop загрузкой - Database: добавлена таблица banners с полями для баннеров и аналитики
This commit is contained in:
776
views/admin/portfolio/add.ejs
Normal file
776
views/admin/portfolio/add.ejs
Normal file
@@ -0,0 +1,776 @@
|
||||
<!-- Add Portfolio Item -->
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-900">
|
||||
<i class="fas fa-plus-circle mr-2 text-blue-600"></i>
|
||||
Добавить новый проект
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Заполните информацию о проекте для добавления в портфолио</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button type="button" onclick="saveDraft()" class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Сохранить черновик
|
||||
</button>
|
||||
<a href="/admin/portfolio" class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Назад к списку
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="portfolioForm" class="p-6">
|
||||
<!-- Progress indicator -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center text-blue-600 relative">
|
||||
<div class="rounded-full h-8 w-8 bg-blue-600 text-white flex items-center justify-center text-sm font-medium">1</div>
|
||||
<span class="ml-3 text-sm font-medium text-blue-600">Основная информация</span>
|
||||
</div>
|
||||
<div class="flex-1 mx-4 h-1 bg-gray-200"></div>
|
||||
<div class="flex items-center text-gray-500">
|
||||
<div class="rounded-full h-8 w-8 bg-gray-200 text-gray-600 flex items-center justify-center text-sm font-medium">2</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-500">Медиа и изображения</span>
|
||||
</div>
|
||||
<div class="flex-1 mx-4 h-1 bg-gray-200"></div>
|
||||
<div class="flex items-center text-gray-500">
|
||||
<div class="rounded-full h-8 w-8 bg-gray-200 text-gray-600 flex items-center justify-center text-sm font-medium">3</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-500">Публикация</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Шаг 1: Основная информация -->
|
||||
<div class="bg-gray-50 rounded-lg p-6">
|
||||
<h4 class="text-lg font-medium text-gray-900 mb-6">
|
||||
<i class="fas fa-info-circle mr-2 text-blue-600"></i>
|
||||
Основная информация о проекте
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<!-- Title -->
|
||||
<div class="sm:col-span-2">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Название проекта *
|
||||
<span class="text-gray-500 font-normal">(отображается в портфолио)</span>
|
||||
</label>
|
||||
<input type="text" name="title" id="title" required
|
||||
placeholder="Например: Интернет-магазин электроники TechStore"
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
<p class="mt-1 text-sm text-gray-500">Используйте описательное название, которое четко передает суть проекта</p>
|
||||
</div>
|
||||
|
||||
<!-- Short Description -->
|
||||
<div class="sm:col-span-2">
|
||||
<label for="shortDescription" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Краткое описание *
|
||||
<span class="text-gray-500 font-normal">(1-2 предложения)</span>
|
||||
</label>
|
||||
<textarea name="shortDescription" id="shortDescription" rows="2" required
|
||||
placeholder="Современный интернет-магазин с удобной системой каталогов и интеграцией платежных систем"
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
maxlength="200"></textarea>
|
||||
<div class="mt-1 flex justify-between text-sm text-gray-500">
|
||||
<span>Отображается в превью проекта</span>
|
||||
<span id="shortDescCount">0/200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700 mb-2">Категория *</label>
|
||||
<select name="category" id="category" required
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
<option value="">Выберите категорию</option>
|
||||
<% categories.forEach(cat => { %>
|
||||
<option value="<%= cat %>"><%= getCategoryName(cat) %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div>
|
||||
<label for="duration" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Длительность проекта
|
||||
<span class="text-gray-500 font-normal">(в днях)</span>
|
||||
</label>
|
||||
<input type="number" name="duration" id="duration" min="1" max="365"
|
||||
placeholder="30"
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Client Name -->
|
||||
<div>
|
||||
<label for="clientName" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Название клиента
|
||||
<span class="text-gray-500 font-normal">(необязательно)</span>
|
||||
</label>
|
||||
<input type="text" name="clientName" id="clientName"
|
||||
placeholder="ООО «ТехноСтор»"
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Demo URL -->
|
||||
<div>
|
||||
<label for="demoUrl" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ссылка на демо
|
||||
<span class="text-gray-500 font-normal">(живая версия)</span>
|
||||
</label>
|
||||
<input type="url" name="demoUrl" id="demoUrl"
|
||||
placeholder="https://techstore.example.com"
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- GitHub URL -->
|
||||
<div>
|
||||
<label for="githubUrl" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
GitHub репозиторий
|
||||
<span class="text-gray-500 font-normal">(если публичный)</span>
|
||||
</label>
|
||||
<input type="url" name="githubUrl" id="githubUrl"
|
||||
placeholder="https://github.com/username/project"
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 2: Детальное описание и технологии -->
|
||||
<div class="bg-gray-50 rounded-lg p-6">
|
||||
<h4 class="text-lg font-medium text-gray-900 mb-6">
|
||||
<i class="fas fa-align-left mr-2 text-blue-600"></i>
|
||||
Описание и технические детали
|
||||
</h4>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-6">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Подробное описание проекта *
|
||||
</label>
|
||||
<div id="descriptionEditor" class="min-h-40 border border-gray-300 rounded-lg"></div>
|
||||
<textarea name="description" id="description" style="display: none;" required></textarea>
|
||||
<p class="mt-2 text-sm text-gray-500">Опишите задачи, решения и результаты проекта</p>
|
||||
</div>
|
||||
|
||||
<!-- Technologies -->
|
||||
<div>
|
||||
<label for="technologies" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Используемые технологии *
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text" id="technologyInput"
|
||||
placeholder="Введите технологию и нажмите Enter"
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
<div class="absolute right-2 top-2">
|
||||
<button type="button" onclick="addTechnology()" class="text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="technologiesList" class="mt-3 flex flex-wrap gap-2"></div>
|
||||
<input type="hidden" name="technologies" id="technologiesHidden">
|
||||
|
||||
<!-- Популярные технологии для быстрого добавления -->
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-gray-600 mb-2">Популярные технологии:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% const popularTechs = ['React', 'Vue.js', 'Node.js', 'Express.js', 'MongoDB', 'PostgreSQL', 'MySQL', 'JavaScript', 'TypeScript', 'HTML5', 'CSS3', 'Tailwind CSS', 'Bootstrap', 'Webpack', 'Docker', 'Git', 'AWS', 'Figma', 'Photoshop']; %>
|
||||
<% popularTechs.forEach(tech => { %>
|
||||
<button type="button" onclick="addTechnologyFromList('<%= tech %>')"
|
||||
class="px-2 py-1 text-xs border border-gray-300 rounded-md text-gray-600 hover:bg-gray-100">
|
||||
<%= tech %>
|
||||
</button>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 3: Медиа контент -->
|
||||
<div class="bg-gray-50 rounded-lg p-6">
|
||||
<h4 class="text-lg font-medium text-gray-900 mb-6">
|
||||
<i class="fas fa-images mr-2 text-blue-600"></i>
|
||||
Изображения и медиа контент
|
||||
</h4>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Main Image Upload -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">
|
||||
Изображения проекта
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div id="dropZone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors">
|
||||
<div class="space-y-4">
|
||||
<div class="mx-auto w-16 h-16 text-gray-400">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<label for="images" class="cursor-pointer">
|
||||
<span class="text-blue-600 font-medium hover:text-blue-500">Загрузить изображения</span>
|
||||
<input id="images" name="images" type="file" class="sr-only" multiple accept="image/*">
|
||||
</label>
|
||||
<span class="text-gray-600"> или перетащите файлы сюда</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
PNG, JPG, WEBP до 10MB каждый. Первое изображение будет использоваться как главное.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Preview and Management -->
|
||||
<div id="imagePreviewContainer" style="display: none;">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h5 class="text-sm font-medium text-gray-900">Загруженные изображения</h5>
|
||||
<button type="button" onclick="clearAllImages()" class="text-red-600 hover:text-red-800 text-sm">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
Удалить все
|
||||
</button>
|
||||
</div>
|
||||
<div id="imagePreview" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"></div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Перетаскивайте изображения для изменения порядка. Первое изображение будет главным.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 4: Настройки публикации -->
|
||||
<div class="bg-gray-50 rounded-lg p-6">
|
||||
<h4 class="text-lg font-medium text-gray-900 mb-6">
|
||||
<i class="fas fa-cog mr-2 text-blue-600"></i>
|
||||
Настройки публикации
|
||||
</h4>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Publication Options -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="isPublished" name="isPublished" type="checkbox"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<label for="isPublished" class="text-sm font-medium text-gray-900">Опубликовать проект</label>
|
||||
<p class="text-sm text-gray-500">Проект будет виден посетителям сайта</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="featured" name="featured" type="checkbox"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<label for="featured" class="text-sm font-medium text-gray-900">Рекомендуемый проект</label>
|
||||
<p class="text-sm text-gray-500">Проект будет показан в топе портфолио</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SEO Fields -->
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h5 class="text-sm font-medium text-gray-900 mb-4">SEO настройки (необязательно)</h5>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="seoTitle" class="block text-sm font-medium text-gray-700 mb-1">SEO заголовок</label>
|
||||
<input type="text" name="seoTitle" id="seoTitle" maxlength="60"
|
||||
placeholder="Будет использован заголовок проекта"
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
<p class="mt-1 text-sm text-gray-500">Рекомендуется до 60 символов</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="seoDescription" class="block text-sm font-medium text-gray-700 mb-1">SEO описание</label>
|
||||
<textarea name="seoDescription" id="seoDescription" rows="2" maxlength="160"
|
||||
placeholder="Будет использовано краткое описание"
|
||||
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"></textarea>
|
||||
<p class="mt-1 text-sm text-gray-500">Рекомендуется до 160 символов</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex justify-between items-center pt-6 border-t border-gray-200">
|
||||
<div class="flex space-x-3">
|
||||
<button type="button" onclick="previewProject()" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-eye mr-2"></i>
|
||||
Предпросмотр
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<a href="/admin/portfolio" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Отмена
|
||||
</a>
|
||||
<button type="button" onclick="saveDraft()" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Сохранить как черновик
|
||||
</button>
|
||||
<button type="submit" id="submitBtn" class="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Сохранить проект
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Include Rich Text Editor -->
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
||||
|
||||
<script>
|
||||
let quillEditor;
|
||||
let selectedTechnologies = [];
|
||||
let uploadedImages = [];
|
||||
|
||||
// Initialize page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeRichTextEditor();
|
||||
initializeImageUpload();
|
||||
initializeTechnologyInput();
|
||||
initializeFormValidation();
|
||||
setupCharacterCounters();
|
||||
});
|
||||
|
||||
// Initialize Rich Text Editor
|
||||
function initializeRichTextEditor() {
|
||||
quillEditor = new Quill('#descriptionEditor', {
|
||||
theme: 'snow',
|
||||
placeholder: 'Опишите проект: цели, задачи, решения, результаты...',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ 'header': [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['blockquote', 'code-block'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
[{ 'script': 'sub'}, { 'script': 'super' }],
|
||||
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||
['link', 'image'],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Sync with hidden textarea
|
||||
quillEditor.on('text-change', function() {
|
||||
document.getElementById('description').value = quillEditor.root.innerHTML;
|
||||
});
|
||||
}
|
||||
|
||||
// Technology management
|
||||
function initializeTechnologyInput() {
|
||||
const input = document.getElementById('technologyInput');
|
||||
|
||||
input.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTechnology();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addTechnology() {
|
||||
const input = document.getElementById('technologyInput');
|
||||
const technology = input.value.trim();
|
||||
|
||||
if (technology && !selectedTechnologies.includes(technology)) {
|
||||
selectedTechnologies.push(technology);
|
||||
input.value = '';
|
||||
updateTechnologiesList();
|
||||
updateTechnologiesInput();
|
||||
}
|
||||
}
|
||||
|
||||
function addTechnologyFromList(tech) {
|
||||
if (!selectedTechnologies.includes(tech)) {
|
||||
selectedTechnologies.push(tech);
|
||||
updateTechnologiesList();
|
||||
updateTechnologiesInput();
|
||||
}
|
||||
}
|
||||
|
||||
function removeTechnology(index) {
|
||||
selectedTechnologies.splice(index, 1);
|
||||
updateTechnologiesList();
|
||||
updateTechnologiesInput();
|
||||
}
|
||||
|
||||
function updateTechnologiesList() {
|
||||
const container = document.getElementById('technologiesList');
|
||||
container.innerHTML = selectedTechnologies.map((tech, index) => `
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
${tech}
|
||||
<button type="button" onclick="removeTechnology(${index})" class="ml-2 text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</span>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updateTechnologiesInput() {
|
||||
document.getElementById('technologiesHidden').value = JSON.stringify(selectedTechnologies);
|
||||
}
|
||||
|
||||
// Image upload functionality
|
||||
function initializeImageUpload() {
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('images');
|
||||
|
||||
// Drag and drop functionality
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||
document.body.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => dropZone.classList.add('border-blue-500', 'bg-blue-50'), false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => dropZone.classList.remove('border-blue-500', 'bg-blue-50'), false);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', handleDrop, false);
|
||||
fileInput.addEventListener('change', (e) => handleFiles(e.target.files), false);
|
||||
}
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
handleFiles(files);
|
||||
}
|
||||
|
||||
async function handleFiles(files) {
|
||||
const validFiles = Array.from(files).filter(file => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showNotification('Можно загружать только изображения', 'error');
|
||||
return false;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
showNotification(`Файл ${file.name} слишком большой (максимум 10MB)`, 'error');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
// Show loading state
|
||||
showLoadingState();
|
||||
|
||||
for (const file of validFiles) {
|
||||
await uploadSingleImage(file);
|
||||
}
|
||||
|
||||
hideLoadingState();
|
||||
updateImagePreview();
|
||||
}
|
||||
|
||||
async function uploadSingleImage(file) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const imageData = {
|
||||
file: file,
|
||||
url: e.target.result,
|
||||
name: file.name,
|
||||
size: file.size
|
||||
};
|
||||
uploadedImages.push(imageData);
|
||||
resolve();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function updateImagePreview() {
|
||||
const container = document.getElementById('imagePreview');
|
||||
const previewContainer = document.getElementById('imagePreviewContainer');
|
||||
|
||||
if (uploadedImages.length === 0) {
|
||||
previewContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
previewContainer.style.display = 'block';
|
||||
container.innerHTML = uploadedImages.map((img, index) => `
|
||||
<div class="relative group" data-index="${index}">
|
||||
<div class="aspect-w-16 aspect-h-9 rounded-lg overflow-hidden border-2 ${index === 0 ? 'border-blue-500' : 'border-gray-200'}">
|
||||
<img src="${img.url}" alt="${img.name}" class="w-full h-32 object-cover">
|
||||
</div>
|
||||
${index === 0 ? '<div class="absolute top-2 left-2 bg-blue-600 text-white text-xs px-2 py-1 rounded">Главное</div>' : ''}
|
||||
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity space-x-1">
|
||||
${index > 0 ? '<button type="button" onclick="moveImage(' + index + ', -1)" class="bg-white text-gray-600 rounded-full w-6 h-6 flex items-center justify-center text-xs shadow hover:bg-gray-50" title="Переместить влево"><i class="fas fa-chevron-left"></i></button>' : ''}
|
||||
${index < uploadedImages.length - 1 ? '<button type="button" onclick="moveImage(' + index + ', 1)" class="bg-white text-gray-600 rounded-full w-6 h-6 flex items-center justify-center text-xs shadow hover:bg-gray-50" title="Переместить вправо"><i class="fas fa-chevron-right"></i></button>' : ''}
|
||||
<button type="button" onclick="removeImage(${index})" class="bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs shadow hover:bg-red-700" title="Удалить">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600 truncate">${img.name}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function moveImage(index, direction) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex >= 0 && newIndex < uploadedImages.length) {
|
||||
[uploadedImages[index], uploadedImages[newIndex]] = [uploadedImages[newIndex], uploadedImages[index]];
|
||||
updateImagePreview();
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage(index) {
|
||||
uploadedImages.splice(index, 1);
|
||||
updateImagePreview();
|
||||
}
|
||||
|
||||
function clearAllImages() {
|
||||
if (confirm('Удалить все загруженные изображения?')) {
|
||||
uploadedImages = [];
|
||||
updateImagePreview();
|
||||
}
|
||||
}
|
||||
|
||||
// Character counters
|
||||
function setupCharacterCounters() {
|
||||
const shortDescInput = document.getElementById('shortDescription');
|
||||
const shortDescCounter = document.getElementById('shortDescCount');
|
||||
|
||||
shortDescInput.addEventListener('input', function() {
|
||||
shortDescCounter.textContent = `${this.value.length}/200`;
|
||||
});
|
||||
}
|
||||
|
||||
// Form validation and submission
|
||||
function initializeFormValidation() {
|
||||
const form = document.getElementById('portfolioForm');
|
||||
form.addEventListener('submit', handleFormSubmit);
|
||||
}
|
||||
|
||||
async function handleFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required fields
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Add text fields
|
||||
const textFields = ['title', 'shortDescription', 'category', 'clientName', 'demoUrl', 'githubUrl', 'seoTitle', 'seoDescription', 'duration'];
|
||||
textFields.forEach(field => {
|
||||
const value = document.getElementById(field)?.value;
|
||||
if (value) formData.append(field, value);
|
||||
});
|
||||
|
||||
// Add description from Quill editor
|
||||
formData.append('description', quillEditor.root.innerHTML);
|
||||
|
||||
// Add technologies
|
||||
formData.append('technologies', JSON.stringify(selectedTechnologies));
|
||||
|
||||
// Add checkboxes
|
||||
formData.append('featured', document.getElementById('featured').checked);
|
||||
formData.append('isPublished', document.getElementById('isPublished').checked);
|
||||
|
||||
// Add images
|
||||
uploadedImages.forEach((img, index) => {
|
||||
formData.append('images', img.file);
|
||||
});
|
||||
|
||||
try {
|
||||
showSubmitLoading();
|
||||
|
||||
const response = await fetch('/admin/portfolio/add', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showNotification('Проект успешно создан!', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/portfolio';
|
||||
}, 1500);
|
||||
} else {
|
||||
showNotification(data.message || 'Ошибка при создании проекта', 'error');
|
||||
hideSubmitLoading();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('Ошибка при создании проекта', 'error');
|
||||
hideSubmitLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const errors = [];
|
||||
|
||||
// Required fields validation
|
||||
const title = document.getElementById('title').value.trim();
|
||||
const shortDescription = document.getElementById('shortDescription').value.trim();
|
||||
const category = document.getElementById('category').value;
|
||||
const description = quillEditor.getText().trim();
|
||||
|
||||
if (!title) errors.push('Введите название проекта');
|
||||
if (!shortDescription) errors.push('Введите краткое описание');
|
||||
if (!category) errors.push('Выберите категорию');
|
||||
if (!description) errors.push('Введите подробное описание');
|
||||
if (selectedTechnologies.length === 0) errors.push('Добавьте хотя бы одну технологию');
|
||||
if (uploadedImages.length === 0) errors.push('Загрузите хотя бы одно изображение');
|
||||
|
||||
if (errors.length > 0) {
|
||||
showNotification('Заполните все обязательные поля:\n' + errors.join('\n'), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save as draft
|
||||
async function saveDraft() {
|
||||
// Set isPublished to false
|
||||
document.getElementById('isPublished').checked = false;
|
||||
|
||||
// Submit form
|
||||
await handleFormSubmit({ preventDefault: () => {} });
|
||||
}
|
||||
|
||||
// Preview functionality
|
||||
function previewProject() {
|
||||
const title = document.getElementById('title').value;
|
||||
const shortDescription = document.getElementById('shortDescription').value;
|
||||
const category = document.getElementById('category').value;
|
||||
|
||||
if (!title || !shortDescription || !category) {
|
||||
showNotification('Заполните основную информацию для предпросмотра', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Open preview in new window/modal
|
||||
const previewData = {
|
||||
title,
|
||||
shortDescription,
|
||||
description: quillEditor.root.innerHTML,
|
||||
category,
|
||||
technologies: selectedTechnologies,
|
||||
images: uploadedImages.map(img => img.url)
|
||||
};
|
||||
|
||||
// Here you can implement preview modal or new window
|
||||
console.log('Preview data:', previewData);
|
||||
showNotification('Функция предпросмотра будет реализована позже', 'info');
|
||||
}
|
||||
|
||||
// UI helpers
|
||||
function showSubmitLoading() {
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Сохранение...';
|
||||
btn.disabled = true;
|
||||
}
|
||||
|
||||
function hideSubmitLoading() {
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.innerHTML = '<i class="fas fa-check mr-2"></i>Сохранить проект';
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
dropZone.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin text-2xl text-blue-600 mb-2"></i><p class="text-blue-600">Обработка изображений...</p></div>';
|
||||
}
|
||||
|
||||
function hideLoadingState() {
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
dropZone.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div class="mx-auto w-16 h-16 text-gray-400">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<label for="images" class="cursor-pointer">
|
||||
<span class="text-blue-600 font-medium hover:text-blue-500">Загрузить изображения</span>
|
||||
<input id="images" name="images" type="file" class="sr-only" multiple accept="image/*">
|
||||
</label>
|
||||
<span class="text-gray-600"> или перетащите файлы сюда</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
PNG, JPG, WEBP до 10MB каждый. Первое изображение будет использоваться как главное.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Re-initialize file input
|
||||
document.getElementById('images').addEventListener('change', (e) => handleFiles(e.target.files), false);
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
// Create notification
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 z-50 px-6 py-4 rounded-lg shadow-lg text-white max-w-md 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 'warning':
|
||||
notification.classList.add('bg-yellow-600');
|
||||
break;
|
||||
case 'info':
|
||||
default:
|
||||
notification.classList.add('bg-blue-600');
|
||||
break;
|
||||
}
|
||||
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-start">
|
||||
<i class="fas ${type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : type === 'warning' ? 'fa-exclamation-triangle' : 'fa-info-circle'} mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<div class="font-medium">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
|
||||
<div class="mt-1 text-sm opacity-90 whitespace-pre-line">${message}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Show notification
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('translate-x-full');
|
||||
}, 100);
|
||||
|
||||
// Hide notification
|
||||
setTimeout(() => {
|
||||
notification.classList.add('translate-x-full');
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(notification)) {
|
||||
document.body.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user