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:
2025-10-22 20:32:16 +09:00
parent 150891b29d
commit 9477ff6de0
69 changed files with 11451 additions and 2321 deletions

View File

@@ -18,14 +18,14 @@
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
<%- include('partials/navigation') %>
<!-- Hero Section -->
<section class="relative bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 py-20 hero-section">
<!-- Hero Section - Компактный -->
<section class="relative bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 hero-section-compact">
<div class="absolute inset-0 bg-black opacity-50 dark:opacity-70"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-5xl md:text-6xl font-bold text-white mb-6">
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
<%- __('about.hero.title') %>
</h1>
<p class="text-xl text-gray-300 dark:text-gray-200 max-w-3xl mx-auto">
<p class="text-lg text-gray-300 dark:text-gray-200 max-w-2xl mx-auto">
<%- __('about.hero.subtitle') %>
</p>
</div>

View File

@@ -0,0 +1,664 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Редактор Баннеров - SmartSolTech Admin</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/fixes.css">
<style>
.upload-zone {
border: 2px dashed #d1d5db;
transition: all 0.3s ease;
}
.upload-zone.dragover {
border-color: #3b82f6;
background-color: #eff6ff;
}
.image-preview {
position: relative;
overflow: hidden;
border-radius: 8px;
}
.image-preview:hover .overlay {
opacity: 1;
}
.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;
opacity: 0;
transition: opacity 0.3s ease;
}
</style>
</head>
<body class="bg-gray-100">
<!-- Admin Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold text-gray-900">
<i class="fas fa-cogs mr-2"></i>
SmartSolTech Admin
</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600">
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
</span>
<a href="/" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-external-link-alt mr-1"></i>
Посмотреть сайт
</a>
<form action="/admin/logout" method="post" class="inline">
<button type="submit" class="text-red-600 hover:text-red-800">
<i class="fas fa-sign-out-alt mr-1"></i>
Выход
</button>
</form>
</div>
</div>
</div>
</header>
<div class="flex">
<!-- Admin Sidebar -->
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
<nav class="mt-5 px-2">
<div class="space-y-1">
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-tachometer-alt mr-3"></i>
Панель управления
</a>
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-briefcase mr-3"></i>
Портфолио
</a>
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cog mr-3"></i>
Услуги
</a>
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-envelope mr-3"></i>
Сообщения
</a>
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-images mr-3"></i>
Медиа
</a>
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cogs mr-3"></i>
Настройки
</a>
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fab fa-telegram mr-3"></i>
Telegram Bot
</a>
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
<i class="fas fa-paint-brush mr-3"></i>
Редактор баннеров
</a>
</div>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-8">
<div class="space-y-6">
<!-- Header -->
<div class="bg-white shadow rounded-lg p-6">
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-paint-brush mr-3 text-blue-600"></i>
Редактор Баннеров
</h1>
<p class="mt-2 text-gray-600">Создание и редактирование баннеров для сайта</p>
</div>
<!-- Controls -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900">Инструменты</h3>
<div class="flex space-x-4">
<button id="refresh-images" class="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors">
<i class="fas fa-sync-alt mr-2"></i>
Обновить
</button>
<button id="upload-modal-btn" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
<i class="fas fa-plus mr-2"></i>
Загрузить Изображения
</button>
</div>
</div>
</div>
<!-- Banner Editor -->
<div class="bg-white shadow rounded-lg p-6">
<!-- Banner Types Tabs -->
<div class="mb-8">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-8">
<button class="banner-tab active py-2 px-1 border-b-2 border-blue-500 font-medium text-sm text-blue-600" data-page="home">
Главная страница
</button>
<button class="banner-tab py-2 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300" data-page="about">
О нас
</button>
<button class="banner-tab py-2 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300" data-page="services">
Услуги
</button>
<button class="banner-tab py-2 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300" data-page="portfolio">
Портфолио
</button>
</nav>
</div>
</div>
<!-- Current Banner Display -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Текущий баннер: <span id="current-page">Главная страница</span>
</h2>
<div id="current-banner" class="relative">
<div class="w-full h-64 bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 rounded-lg flex items-center justify-center">
<div class="text-center text-white">
<h3 class="text-4xl font-bold mb-2">Текущий Баннер</h3>
<p class="text-xl opacity-90">Нажмите на изображение ниже, чтобы заменить</p>
</div>
</div>
<div class="mt-4 flex justify-between items-center">
<div class="text-sm text-gray-600 dark:text-gray-400">
<span id="banner-info">Используется CSS градиент</span>
</div>
<button id="remove-banner" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm" style="display: none;">
<i class="fas fa-trash mr-1"></i>
Удалить
</button>
</div>
</div>
</div>
<!-- Image Gallery -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
Галерея изображений
</h2>
<div id="loading" class="text-center py-8" style="display: none;">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Загрузка...</p>
</div>
<div id="images-grid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<!-- Изображения будут загружены динамически -->
</div>
<div id="no-images" class="text-center py-8" style="display: none;">
<i class="fas fa-images text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-600 dark:text-gray-400">Нет загруженных изображений</p>
<button class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors" onclick="document.getElementById('upload-modal-btn').click()">
Загрузить первое изображение
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Upload Modal -->
<div id="upload-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Загрузить изображения</h3>
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- Upload Zone -->
<div id="upload-zone" class="upload-zone rounded-lg p-8 text-center mb-6">
<div class="mb-4">
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-2"></i>
<p class="text-lg text-gray-600 dark:text-gray-400 mb-2">
Перетащите изображения сюда или нажмите для выбора
</p>
<p class="text-sm text-gray-500">
Поддерживаются: JPG, PNG, GIF, WebP (максимум 10MB каждое)
</p>
</div>
<input type="file" id="file-input" multiple accept="image/*" class="hidden">
<button type="button" onclick="document.getElementById('file-input').click()" class="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
Выбрать файлы
</button>
</div>
<!-- Upload Progress -->
<div id="upload-progress" class="hidden mb-6">
<div class="bg-gray-200 rounded-full h-2 mb-2">
<div id="progress-bar" class="bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p id="progress-text" class="text-sm text-gray-600 dark:text-gray-400 text-center">Загрузка...</p>
</div>
<!-- Preview Area -->
<div id="preview-area" class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6" style="display: none;">
<!-- Previews will be added here -->
</div>
<!-- Upload Button -->
<div class="flex justify-end">
<button id="upload-btn" class="px-6 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" disabled>
<i class="fas fa-upload mr-2"></i>
Загрузить
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script>
class BannerEditor {
constructor() {
this.currentPage = 'home';
this.selectedFiles = [];
this.bannerSettings = {
home: { type: 'gradient', image: null },
about: { type: 'gradient', image: null },
services: { type: 'gradient', image: null },
portfolio: { type: 'gradient', image: null }
};
this.init();
}
init() {
this.loadImages();
this.setupEventListeners();
this.loadBannerSettings();
}
setupEventListeners() {
// Tab switching
document.querySelectorAll('.banner-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
const page = e.target.dataset.page;
this.switchPage(page);
});
});
// Upload modal
document.getElementById('upload-modal-btn').addEventListener('click', () => {
document.getElementById('upload-modal').classList.remove('hidden');
});
document.getElementById('close-modal').addEventListener('click', () => {
this.closeModal();
});
// File upload
const fileInput = document.getElementById('file-input');
const uploadZone = document.getElementById('upload-zone');
fileInput.addEventListener('change', (e) => {
this.handleFiles(e.target.files);
});
// Drag and drop
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
this.handleFiles(e.dataTransfer.files);
});
// Upload button
document.getElementById('upload-btn').addEventListener('click', () => {
this.uploadFiles();
});
// Refresh images
document.getElementById('refresh-images').addEventListener('click', () => {
this.loadImages();
});
}
switchPage(page) {
this.currentPage = page;
// Update active tab
document.querySelectorAll('.banner-tab').forEach(tab => {
tab.classList.remove('active', 'border-blue-500', 'text-blue-600');
tab.classList.add('border-transparent', 'text-gray-500');
});
document.querySelector(`[data-page="${page}"]`).classList.add('active', 'border-blue-500', 'text-blue-600');
document.querySelector(`[data-page="${page}"]`).classList.remove('border-transparent', 'text-gray-500');
// Update current page display
const pageNames = {
home: 'Главная страница',
about: 'О нас',
services: 'Услуги',
portfolio: 'Портфолио'
};
document.getElementById('current-page').textContent = pageNames[page];
this.updateCurrentBanner();
}
updateCurrentBanner() {
const banner = this.bannerSettings[this.currentPage];
const bannerElement = document.getElementById('current-banner').querySelector('div');
const infoElement = document.getElementById('banner-info');
const removeBtn = document.getElementById('remove-banner');
if (banner.image) {
bannerElement.style.backgroundImage = `url(${banner.image})`;
bannerElement.style.backgroundSize = 'cover';
bannerElement.style.backgroundPosition = 'center';
bannerElement.innerHTML = '';
infoElement.textContent = `Изображение: ${banner.image.split('/').pop()}`;
removeBtn.style.display = 'block';
} else {
bannerElement.style.backgroundImage = '';
bannerElement.className = 'w-full h-64 bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 rounded-lg flex items-center justify-center';
bannerElement.innerHTML = `
<div class="text-center text-white">
<h3 class="text-4xl font-bold mb-2">Текущий Баннер</h3>
<p class="text-xl opacity-90">Нажмите на изображение ниже, чтобы заменить</p>
</div>
`;
infoElement.textContent = 'Используется CSS градиент';
removeBtn.style.display = 'none';
}
}
async loadImages() {
const loading = document.getElementById('loading');
const grid = document.getElementById('images-grid');
const noImages = document.getElementById('no-images');
loading.style.display = 'block';
grid.innerHTML = '';
noImages.style.display = 'none';
try {
const response = await fetch('/media/list');
const data = await response.json();
if (data.success && data.images.length > 0) {
grid.innerHTML = '';
data.images.forEach(image => {
this.addImageToGrid(image);
});
} else {
noImages.style.display = 'block';
}
} catch (error) {
console.error('Error loading images:', error);
this.showNotification('Ошибка загрузки изображений', 'error');
} finally {
loading.style.display = 'none';
}
}
addImageToGrid(image) {
const grid = document.getElementById('images-grid');
const imageElement = document.createElement('div');
imageElement.className = 'image-preview bg-gray-200 rounded-lg cursor-pointer';
imageElement.innerHTML = `
<img src="${image.url}" alt="${image.filename}" class="w-full h-32 object-cover rounded-lg">
<div class="overlay">
<div class="flex space-x-2">
<button class="use-image px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm" data-url="${image.url}">
<i class="fas fa-check mr-1"></i>
Использовать
</button>
<button class="delete-image px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm" data-filename="${image.filename}">
<i class="fas fa-trash mr-1"></i>
Удалить
</button>
</div>
</div>
<div class="p-2">
<p class="text-xs text-gray-600 truncate">${image.filename}</p>
<p class="text-xs text-gray-500">${this.formatFileSize(image.size)}</p>
</div>
`;
// Add event listeners
imageElement.querySelector('.use-image').addEventListener('click', (e) => {
e.stopPropagation();
this.setImageAsBanner(image.url);
});
imageElement.querySelector('.delete-image').addEventListener('click', (e) => {
e.stopPropagation();
this.deleteImage(image.filename);
});
grid.appendChild(imageElement);
}
setImageAsBanner(imageUrl) {
this.bannerSettings[this.currentPage] = {
type: 'image',
image: imageUrl
};
this.updateCurrentBanner();
this.saveBannerSettings();
this.showNotification('Баннер обновлен!', 'success');
}
async deleteImage(filename) {
if (!confirm('Вы уверены, что хотите удалить это изображение?')) {
return;
}
try {
const response = await fetch(`/media/${filename}`, {
method: 'DELETE'
});
if (response.ok) {
this.loadImages();
this.showNotification('Изображение удалено', 'success');
} else {
throw new Error('Failed to delete image');
}
} catch (error) {
console.error('Error deleting image:', error);
this.showNotification('Ошибка удаления изображения', 'error');
}
}
handleFiles(files) {
this.selectedFiles = Array.from(files).filter(file => {
if (file.type.startsWith('image/')) {
if (file.size <= 10 * 1024 * 1024) { // 10MB
return true;
} else {
this.showNotification(`Файл ${file.name} слишком большой (максимум 10MB)`, 'error');
}
} else {
this.showNotification(`Файл ${file.name} не является изображением`, 'error');
}
return false;
});
this.showPreviews();
document.getElementById('upload-btn').disabled = this.selectedFiles.length === 0;
}
showPreviews() {
const previewArea = document.getElementById('preview-area');
previewArea.innerHTML = '';
previewArea.style.display = this.selectedFiles.length > 0 ? 'grid' : 'none';
this.selectedFiles.forEach((file, index) => {
const reader = new FileReader();
reader.onload = (e) => {
const preview = document.createElement('div');
preview.className = 'relative';
preview.innerHTML = `
<img src="${e.target.result}" alt="${file.name}" class="w-full h-24 object-cover rounded">
<button class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600" onclick="bannerEditor.removePreview(${index})">
<i class="fas fa-times"></i>
</button>
<p class="text-xs text-gray-600 mt-1 truncate">${file.name}</p>
`;
previewArea.appendChild(preview);
};
reader.readAsDataURL(file);
});
}
removePreview(index) {
this.selectedFiles.splice(index, 1);
this.showPreviews();
document.getElementById('upload-btn').disabled = this.selectedFiles.length === 0;
}
async uploadFiles() {
if (this.selectedFiles.length === 0) return;
const progressContainer = document.getElementById('upload-progress');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const uploadBtn = document.getElementById('upload-btn');
progressContainer.classList.remove('hidden');
uploadBtn.disabled = true;
const formData = new FormData();
this.selectedFiles.forEach(file => {
formData.append('images', file);
});
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
progressBar.style.width = percentComplete + '%';
progressText.textContent = `Загрузка... ${Math.round(percentComplete)}%`;
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.success) {
this.showNotification('Изображения загружены успешно!', 'success');
this.closeModal();
this.loadImages();
} else {
throw new Error(response.message);
}
} else {
throw new Error('Upload failed');
}
});
xhr.addEventListener('error', () => {
throw new Error('Network error');
});
xhr.open('POST', '/media/upload-multiple');
xhr.send(formData);
} catch (error) {
console.error('Upload error:', error);
this.showNotification('Ошибка загрузки изображений', 'error');
} finally {
progressContainer.classList.add('hidden');
uploadBtn.disabled = false;
progressBar.style.width = '0%';
}
}
closeModal() {
document.getElementById('upload-modal').classList.add('hidden');
this.selectedFiles = [];
document.getElementById('preview-area').style.display = 'none';
document.getElementById('upload-btn').disabled = true;
document.getElementById('upload-progress').classList.add('hidden');
}
loadBannerSettings() {
const saved = localStorage.getItem('bannerSettings');
if (saved) {
this.bannerSettings = JSON.parse(saved);
}
this.updateCurrentBanner();
}
saveBannerSettings() {
localStorage.setItem('bannerSettings', JSON.stringify(this.bannerSettings));
}
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];
}
showNotification(message, type = 'info') {
// Simple notification - you can enhance this with a proper notification system
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);
}
}
// Initialize when page loads
let bannerEditor;
document.addEventListener('DOMContentLoaded', () => {
bannerEditor = new BannerEditor();
});
</script>
</div>
</main>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,117 @@
<!-- Contacts 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">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<i class="fas fa-envelope mr-2"></i>
Управление сообщениями
</h3>
<div class="flex items-center space-x-2">
<!-- Status Filter -->
<select id="statusFilter" class="border-gray-300 rounded-md shadow-sm text-sm">
<option value="all" <%= currentStatus === 'all' ? 'selected' : '' %>>Все статусы</option>
<option value="new" <%= currentStatus === 'new' ? 'selected' : '' %>>Новые</option>
<option value="in_progress" <%= currentStatus === 'in_progress' ? 'selected' : '' %>>В работе</option>
<option value="completed" <%= currentStatus === 'completed' ? 'selected' : '' %>>Завершенные</option>
</select>
</div>
</div>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul role="list" class="divide-y divide-gray-200">
<% if (contacts && contacts.length > 0) { %>
<% contacts.forEach(contact => { %>
<li>
<a href="/admin/contacts/<%= contact.id %>" class="block hover:bg-gray-50">
<div class="px-4 py-4 flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<% if (!contact.isRead) { %>
<div class="h-2 w-2 bg-blue-600 rounded-full"></div>
<% } else { %>
<div class="h-2 w-2"></div>
<% } %>
</div>
<div class="ml-4 min-w-0 flex-1">
<div class="flex items-center">
<p class="text-sm font-medium text-gray-900 truncate">
<%= contact.name %>
</p>
<% if (contact.serviceInterest) { %>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<%= contact.serviceInterest %>
</span>
<% } %>
</div>
<div class="flex items-center mt-1">
<p class="text-sm text-gray-500 truncate">
<%= contact.email %>
</p>
<span class="mx-2 text-gray-300">•</span>
<p class="text-sm text-gray-500">
<%= new Date(contact.createdAt).toLocaleDateString('ru-RU') %>
</p>
</div>
<p class="text-sm text-gray-500 mt-1 truncate">
<%= contact.message.substring(0, 100) %>...
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<!-- Status Badge -->
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<%= contact.status === 'new' ? 'bg-green-100 text-green-800' :
contact.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800' %>">
<%= contact.status === 'new' ? 'Новое' :
contact.status === 'in_progress' ? 'В работе' : 'Завершено' %>
</span>
<!-- Priority -->
<% if (contact.priority === 'high') { %>
<i class="fas fa-exclamation-triangle text-red-500"></i>
<% } %>
</div>
</div>
</a>
</li>
<% }) %>
<% } else { %>
<li>
<div class="px-4 py-8 text-center">
<i class="fas fa-envelope text-4xl text-gray-300 mb-4"></i>
<p class="text-gray-500">Сообщения не найдены</p>
</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 %>&status=<%= currentStatus %>" 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 %>&status=<%= currentStatus %>" 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>
<script>
document.getElementById('statusFilter').addEventListener('change', function() {
const status = this.value;
const url = new URL(window.location);
url.searchParams.set('status', status);
url.searchParams.delete('page'); // Reset to first page
window.location.href = url.toString();
});
</script>

View File

@@ -0,0 +1,219 @@
<!-- Contact Details -->
<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">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<i class="fas fa-envelope mr-2"></i>
Детали сообщения
</h3>
<a href="/admin/contacts" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
<i class="fas fa-arrow-left mr-1"></i>
Назад к списку
</a>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<!-- Contact Information -->
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="text-lg font-medium text-gray-900 mb-4">Информация о контакте</h4>
<dl class="space-y-3">
<div>
<dt class="text-sm font-medium text-gray-500">Имя</dt>
<dd class="mt-1 text-sm text-gray-900"><%= contact.name %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Email</dt>
<dd class="mt-1 text-sm text-gray-900">
<a href="mailto:<%= contact.email %>" class="text-blue-600 hover:text-blue-800">
<%= contact.email %>
</a>
</dd>
</div>
<% if (contact.phone) { %>
<div>
<dt class="text-sm font-medium text-gray-500">Телефон</dt>
<dd class="mt-1 text-sm text-gray-900">
<a href="tel:<%= contact.phone %>" class="text-blue-600 hover:text-blue-800">
<%= contact.phone %>
</a>
</dd>
</div>
<% } %>
<div>
<dt class="text-sm font-medium text-gray-500">Дата создания</dt>
<dd class="mt-1 text-sm text-gray-900">
<%= new Date(contact.createdAt).toLocaleString('ru-RU') %>
</dd>
</div>
</dl>
</div>
<!-- Project Details -->
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="text-lg font-medium text-gray-900 mb-4">Детали проекта</h4>
<dl class="space-y-3">
<% if (contact.serviceInterest) { %>
<div>
<dt class="text-sm font-medium text-gray-500">Интересующая услуга</dt>
<dd class="mt-1">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<%= contact.serviceInterest %>
</span>
</dd>
</div>
<% } %>
<% if (contact.budget) { %>
<div>
<dt class="text-sm font-medium text-gray-500">Бюджет</dt>
<dd class="mt-1 text-sm text-gray-900"><%= contact.budget %></dd>
</div>
<% } %>
<% if (contact.timeline) { %>
<div>
<dt class="text-sm font-medium text-gray-500">Временные рамки</dt>
<dd class="mt-1 text-sm text-gray-900"><%= contact.timeline %></dd>
</div>
<% } %>
<div>
<dt class="text-sm font-medium text-gray-500">Статус</dt>
<dd class="mt-1">
<select id="statusSelect" data-contact-id="<%= contact.id %>"
class="border-gray-300 rounded-md shadow-sm text-sm">
<option value="new" <%= contact.status === 'new' ? 'selected' : '' %>>Новое</option>
<option value="in_progress" <%= contact.status === 'in_progress' ? 'selected' : '' %>>В работе</option>
<option value="completed" <%= contact.status === 'completed' ? 'selected' : '' %>>Завершено</option>
</select>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Приоритет</dt>
<dd class="mt-1">
<select id="prioritySelect" data-contact-id="<%= contact.id %>"
class="border-gray-300 rounded-md shadow-sm text-sm">
<option value="low" <%= contact.priority === 'low' ? 'selected' : '' %>>Низкий</option>
<option value="medium" <%= contact.priority === 'medium' ? 'selected' : '' %>>Средний</option>
<option value="high" <%= contact.priority === 'high' ? 'selected' : '' %>>Высокий</option>
</select>
</dd>
</div>
</dl>
</div>
</div>
<!-- Message -->
<div class="mt-6">
<h4 class="text-lg font-medium text-gray-900 mb-3">Сообщение</h4>
<div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-700 whitespace-pre-wrap"><%= contact.message %></p>
</div>
</div>
<!-- Actions -->
<div class="mt-6 flex space-x-3">
<button onclick="sendTelegramNotification('<%= contact.id %>')"
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium">
<i class="fab fa-telegram-plane mr-1"></i>
Отправить в Telegram
</button>
<a href="mailto:<%= contact.email %>?subject=Re: <%= encodeURIComponent(contact.serviceInterest || 'Ваш запрос') %>"
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium">
<i class="fas fa-reply mr-1"></i>
Ответить по email
</a>
<button onclick="deleteContact('<%= contact.id %>')"
class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md text-sm font-medium">
<i class="fas fa-trash mr-1"></i>
Удалить
</button>
</div>
</div>
</div>
<script>
// Update status
document.getElementById('statusSelect').addEventListener('change', function() {
updateContactField('status', this.value, this.dataset.contactId);
});
// Update priority
document.getElementById('prioritySelect').addEventListener('change', function() {
updateContactField('priority', this.value, this.dataset.contactId);
});
function updateContactField(field, value, contactId) {
fetch(`/api/admin/contacts/${contactId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ [field]: value })
})
.then(response => response.json())
.then(data => {
if (!data.success) {
alert('Ошибка при обновлении контакта');
}
})
.catch(error => {
console.error('Error:', error);
alert('Ошибка при обновлении контакта');
});
}
function sendTelegramNotification(contactId) {
fetch(`/api/admin/contacts/${contactId}/telegram`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Уведомление отправлено в Telegram');
} else {
alert('Ошибка при отправке уведомления');
}
})
.catch(error => {
console.error('Error:', error);
alert('Ошибка при отправке уведомления');
});
}
function deleteContact(contactId) {
if (confirm('Вы уверены, что хотите удалить это сообщение?')) {
fetch(`/api/admin/contacts/${contactId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.href = '/admin/contacts';
} else {
alert('Ошибка при удалении сообщения');
}
})
.catch(error => {
console.error('Error:', error);
alert('Ошибка при удалении сообщения');
});
}
}
</script>

323
views/admin/dashboard.ejs Normal file
View File

@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> - SmartSolTech Admin</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/fixes.css">
</head>
<body class="bg-gray-100">
<!-- Admin Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold text-gray-900">
<i class="fas fa-cogs mr-2"></i>
SmartSolTech Admin
</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600">
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
</span>
<a href="/" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-external-link-alt mr-1"></i>
Посмотреть сайт
</a>
<form action="/admin/logout" method="post" class="inline">
<button type="submit" class="text-red-600 hover:text-red-800">
<i class="fas fa-sign-out-alt mr-1"></i>
Выход
</button>
</form>
</div>
</div>
</div>
</header>
<div class="flex">
<!-- Admin Sidebar -->
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
<nav class="mt-5 px-2">
<div class="space-y-1">
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
<i class="fas fa-tachometer-alt mr-3"></i>
Панель управления
</a>
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-briefcase mr-3"></i>
Портфолио
</a>
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cog mr-3"></i>
Услуги
</a>
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-envelope mr-3"></i>
Сообщения
</a>
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-images mr-3"></i>
Медиа
</a>
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cogs mr-3"></i>
Настройки
</a>
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fab fa-telegram mr-3"></i>
Telegram Bot
</a>
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-paint-brush mr-3"></i>
Редактор баннеров
</a>
</div>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-8">
<div class="space-y-6">
<!-- Header -->
<div class="bg-white shadow rounded-lg p-6">
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-tachometer-alt mr-3 text-blue-600"></i>
Панель управления
</h1>
<p class="mt-2 text-gray-600">Обзор основных показателей сайта</p>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Portfolio Projects -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-briefcase text-blue-600 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Проекты
</dt>
<dd class="text-lg font-medium text-gray-900">
<%= stats.portfolioCount || 0 %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<a href="/admin/portfolio" class="font-medium text-blue-600 hover:text-blue-500">
Посмотреть всё
</a>
</div>
</div>
</div>
<!-- Services -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-cog text-green-600 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Услуги
</dt>
<dd class="text-lg font-medium text-gray-900">
<%= stats.servicesCount || 0 %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<a href="/admin/services" class="font-medium text-green-600 hover:text-green-500">
Посмотреть всё
</a>
</div>
</div>
</div>
<!-- Contact Messages -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-envelope text-purple-600 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Сообщения
</dt>
<dd class="text-lg font-medium text-gray-900">
<%= stats.contactsCount || 0 %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<a href="/admin/contacts" class="font-medium text-purple-600 hover:text-purple-500">
Посмотреть всё
</a>
</div>
</div>
</div>
<!-- Users -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-users text-orange-600 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Пользователи
</dt>
<dd class="text-lg font-medium text-gray-900">
<%= stats.usersCount || 0 %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<a href="/admin/users" class="font-medium text-orange-600 hover:text-orange-500">
Посмотреть всё
</a>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Recent Portfolio Projects -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">
Последние проекты
</h3>
</div>
<div class="p-6">
<% if (recentPortfolio && recentPortfolio.length > 0) { %>
<div class="space-y-4">
<% recentPortfolio.forEach(function(project) { %>
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<i class="fas fa-briefcase text-blue-600"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
<%= project.title %>
</p>
<p class="text-sm text-gray-500">
<%= project.category %>
</p>
</div>
<div class="flex-shrink-0">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<%= project.status %>
</span>
</div>
</div>
<% }); %>
</div>
<% } else { %>
<p class="text-gray-500 text-sm">Нет недавних проектов</p>
<% } %>
</div>
</div>
<!-- Recent Contact Messages -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">
Последние сообщения
</h3>
</div>
<div class="p-6">
<% if (recentContacts && recentContacts.length > 0) { %>
<div class="space-y-4">
<% recentContacts.forEach(function(contact) { %>
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<i class="fas fa-envelope text-purple-600"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
<%= contact.name %>
</p>
<p class="text-sm text-gray-500 truncate">
<%= contact.email %>
</p>
</div>
<div class="flex-shrink-0">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<%= contact.status %>
</span>
</div>
</div>
<% }); %>
</div>
<% } else { %>
<p class="text-gray-500 text-sm">Нет недавних сообщений</p>
<% } %>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">
Быстрые действия
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a href="/admin/portfolio/new"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
<i class="fas fa-plus mr-2"></i>
Добавить проект
</a>
<a href="/admin/services/new"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-plus mr-2"></i>
Добавить услугу
</a>
<a href="/admin/settings"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gray-600 hover:bg-gray-700">
<i class="fas fa-cogs mr-2"></i>
Настройки сайта
</a>
</div>
</div>
</div>
</main>
</div>
<!-- JavaScript -->
<script src="/js/main.js"></script>
</body>
</html>

23
views/admin/error.ejs Normal file
View File

@@ -0,0 +1,23 @@
<%- include('layout', { title: title, user: user }) %>
<div class="max-w-lg mx-auto text-center">
<div class="mb-8">
<i class="fas fa-exclamation-triangle text-red-500 text-6xl mb-4"></i>
<h1 class="text-2xl font-bold text-gray-900 mb-2">Ошибка</h1>
<p class="text-gray-600"><%= message %></p>
</div>
<div class="space-y-4">
<a href="/admin/dashboard"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
<i class="fas fa-arrow-left mr-2"></i>
Вернуться к панели управления
</a>
<div>
<a href="/" class="text-blue-600 hover:text-blue-500 text-sm">
Перейти на главную страницу сайта
</a>
</div>
</div>
</div>

104
views/admin/layout.ejs Normal file
View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="<%= currentLanguage %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> - SmartSolTech Admin</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/fixes.css">
<style>
.admin-sidebar {
min-height: calc(100vh - 64px);
}
</style>
</head>
<body class="bg-gray-100">
<!-- Admin Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold text-gray-900">
<i class="fas fa-cogs mr-2"></i>
SmartSolTech Admin
</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600">
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
</span>
<a href="/" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-external-link-alt mr-1"></i>
Посмотреть сайт
</a>
<form action="/admin/logout" method="post" class="inline">
<button type="submit" class="text-red-600 hover:text-red-800">
<i class="fas fa-sign-out-alt mr-1"></i>
Выход
</button>
</form>
</div>
</div>
</div>
</header>
<div class="flex">
<!-- Admin Sidebar -->
<aside class="w-64 bg-white shadow-sm admin-sidebar">
<nav class="mt-5 px-2">
<div class="space-y-1">
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-tachometer-alt mr-3"></i>
Панель управления
</a>
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-briefcase mr-3"></i>
Портфолио
</a>
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cog mr-3"></i>
Услуги
</a>
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-envelope mr-3"></i>
Сообщения
</a>
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-images mr-3"></i>
Медиа
</a>
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cogs mr-3"></i>
Настройки
</a>
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fab fa-telegram mr-3"></i>
Telegram Bot
</a>
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-paint-brush mr-3"></i>
Редактор баннеров
</a>
</div>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-8">
<%- body %>
</main>
</div>
<!-- JavaScript -->
<script src="/js/main.js"></script>
</body>
</html>

81
views/admin/login.ejs Normal file
View File

@@ -0,0 +1,81 @@
о<!DOCTYPE html>
<html lang="<%= currentLanguage %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход в админ панель - SmartSolTech</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/fixes.css">
</head>
<body class="bg-gray-50 flex items-center justify-center min-h-screen">
<div class="max-w-md w-full space-y-8">
<div>
<div class="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
<i class="fas fa-lock text-blue-600 text-xl"></i>
</div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Вход в админ панель
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Войдите в свой аккаунт для управления сайтом
</p>
</div>
<form class="mt-8 space-y-6" action="/admin/login" method="POST">
<% if (typeof error !== 'undefined') { %>
<div class="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded">
<i class="fas fa-exclamation-triangle mr-2"></i>
<%= error %>
</div>
<% } %>
<div class="rounded-md shadow-sm space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
Email
</label>
<input id="email" name="email" type="email" required
class="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="admin@smartsoltech.com">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">
Пароль
</label>
<input id="password" name="password" type="password" required
class="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Введите пароль">
</div>
</div>
<div>
<button type="submit"
class="group relative w-full flex justify-center py-2 px-4 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">
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<i class="fas fa-sign-in-alt text-blue-500 group-hover:text-blue-400"></i>
</span>
Войти
</button>
</div>
<div class="text-center">
<a href="/" class="text-blue-600 hover:text-blue-500 text-sm">
<i class="fas fa-arrow-left mr-1"></i>
Вернуться на сайт
</a>
</div>
</form>
</div>
<!-- JavaScript -->
<script src="/js/main.js"></script>
</body>
</html>

848
views/admin/media.ejs Normal file
View File

@@ -0,0 +1,848 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Медиа Галерея - SmartSolTech Admin</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/fixes.css">
</head>
<body class="bg-gray-100">
<!-- Admin Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold text-gray-900">
<i class="fas fa-cogs mr-2"></i>
SmartSolTech Admin
</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600">
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
</span>
<a href="/" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-external-link-alt mr-1"></i>
Посмотреть сайт
</a>
<form action="/admin/logout" method="post" class="inline">
<button type="submit" class="text-red-600 hover:text-red-800">
<i class="fas fa-sign-out-alt mr-1"></i>
Выход
</button>
</form>
</div>
</div>
</div>
</header>
<div class="flex">
<!-- Admin Sidebar -->
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
<nav class="mt-5 px-2">
<div class="space-y-1">
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-tachometer-alt mr-3"></i>
Панель управления
</a>
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-briefcase mr-3"></i>
Портфолио
</a>
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cog mr-3"></i>
Услуги
</a>
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-envelope mr-3"></i>
Сообщения
</a>
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
<i class="fas fa-images mr-3"></i>
Медиа
</a>
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cogs mr-3"></i>
Настройки
</a>
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-paint-brush mr-3"></i>
Редактор баннеров
</a>
</div>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-8">
<div class="space-y-6">
<!-- Header -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-images mr-3 text-blue-600"></i>
Медиа Галерея
</h1>
<p class="mt-2 text-gray-600">Управление изображениями и файлами сайта</p>
</div>
<div class="flex space-x-3">
<button id="refresh-btn" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-sync-alt mr-2"></i>
Обновить
</button>
<button id="upload-btn" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-upload mr-2"></i>
Загрузить файлы
</button>
</div>
</div>
</div>
<!-- Upload Zone -->
<div id="upload-zone" class="bg-white shadow rounded-lg p-8 border-2 border-dashed border-gray-300 text-center" style="display: none;">
<div class="mb-4">
<i class="fas fa-cloud-upload-alt text-6xl text-gray-400 mb-4"></i>
<p class="text-xl text-gray-600 mb-2">Перетащите файлы сюда или нажмите для выбора</p>
<p class="text-gray-500">Поддерживаются: JPG, PNG, GIF, SVG (максимум 10MB каждый)</p>
</div>
<input type="file" id="file-input" multiple accept="image/*" class="hidden">
<button type="button" onclick="document.getElementById('file-input').click()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg">
Выбрать файлы
</button>
<button id="cancel-upload" class="bg-gray-500 hover:bg-gray-600 text-white px-6 py-3 rounded-lg ml-3">
Отмена
</button>
</div>
<!-- Upload Progress -->
<div id="upload-progress" class="bg-white shadow rounded-lg p-6" style="display: none;">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Загрузка файлов</h3>
<div class="space-y-3" id="progress-list">
<!-- Progress items will be added here -->
</div>
</div>
<!-- Filter and Search -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div class="flex space-x-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Тип файла</label>
<select id="file-type-filter" class="border border-gray-300 rounded-lg px-3 py-2">
<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>
<label class="block text-sm font-medium text-gray-700 mb-1">Размер</label>
<select id="size-filter" class="border border-gray-300 rounded-lg px-3 py-2">
<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>
<label class="block text-sm font-medium text-gray-700 mb-1">Поиск</label>
<div class="relative">
<input type="text" id="search-input" placeholder="Поиск по имени файла..." class="border border-gray-300 rounded-lg px-3 py-2 pr-10 w-64">
<i class="fas fa-search absolute right-3 top-3 text-gray-400"></i>
</div>
</div>
</div>
</div>
<!-- Media Grid -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-semibold text-gray-900">Файлы</h3>
<div class="flex items-center space-x-4">
<span id="file-count" class="text-sm text-gray-600">Загрузка...</span>
<div class="flex space-x-2">
<button id="grid-view" class="p-2 text-gray-600 hover:text-gray-900 border border-gray-300 rounded">
<i class="fas fa-th-large"></i>
</button>
<button id="list-view" class="p-2 text-gray-600 hover:text-gray-900 border border-gray-300 rounded">
<i class="fas fa-list"></i>
</button>
</div>
</div>
</div>
<!-- Loading State -->
<div id="loading" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="mt-2 text-gray-600">Загрузка медиа файлов...</p>
</div>
<!-- Empty State -->
<div id="empty-state" class="text-center py-12" style="display: none;">
<i class="fas fa-images text-6xl text-gray-400 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Нет загруженных файлов</h3>
<p class="text-gray-600 mb-6">Начните с загрузки ваших первых изображений</p>
<button onclick="document.getElementById('upload-btn').click()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg">
<i class="fas fa-upload mr-2"></i>
Загрузить файлы
</button>
</div>
<!-- Media Grid -->
<div id="media-grid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<!-- Media items will be loaded here -->
</div>
<!-- Media List -->
<div id="media-list" class="space-y-4" style="display: none;">
<!-- List items will be loaded here -->
</div>
<!-- Pagination -->
<div id="pagination" class="mt-8 flex justify-center" style="display: none;">
<nav class="flex space-x-2">
<button id="prev-page" class="px-3 py-2 bg-gray-200 text-gray-600 rounded hover:bg-gray-300 disabled:opacity-50">
<i class="fas fa-chevron-left"></i>
</button>
<div id="page-numbers" class="flex space-x-2">
<!-- Page numbers will be added here -->
</div>
<button id="next-page" class="px-3 py-2 bg-gray-200 text-gray-600 rounded hover:bg-gray-300 disabled:opacity-50">
<i class="fas fa-chevron-right"></i>
</button>
</nav>
</div>
</div>
</div>
</main>
</div>
<!-- Media Preview Modal -->
<div id="preview-modal" class="fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center" style="display: none;">
<div class="bg-white rounded-lg shadow-lg max-w-4xl max-h-[90vh] w-full mx-4 overflow-hidden">
<div class="p-4 border-b flex justify-between items-center">
<h3 id="modal-title" class="text-lg font-semibold text-gray-900">Предпросмотр файла</h3>
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="p-6">
<div class="flex flex-col lg:flex-row space-y-6 lg:space-y-0 lg:space-x-6">
<div class="flex-1">
<img id="modal-image" src="" alt="" class="w-full h-auto rounded-lg shadow">
</div>
<div class="w-full lg:w-80">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Имя файла</label>
<input id="modal-filename" type="text" class="w-full border border-gray-300 rounded-lg px-3 py-2" readonly>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">URL</label>
<div class="flex">
<input id="modal-url" type="text" class="flex-1 border border-gray-300 rounded-l-lg px-3 py-2" readonly>
<button onclick="copyToClipboard()" class="bg-gray-100 border border-l-0 border-gray-300 rounded-r-lg px-3 py-2 hover:bg-gray-200">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Размер</label>
<p id="modal-size" class="text-sm text-gray-600">-</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Тип</label>
<p id="modal-type" class="text-sm text-gray-600">-</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Ширина</label>
<p id="modal-width" class="text-sm text-gray-600">-</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Высота</label>
<p id="modal-height" class="text-sm text-gray-600">-</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Загружено</label>
<p id="modal-date" class="text-sm text-gray-600">-</p>
</div>
<div class="border-t pt-4 space-y-3">
<button onclick="downloadFile()" class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg">
<i class="fas fa-download mr-2"></i>
Скачать
</button>
<button onclick="deleteFile()" class="w-full bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg">
<i class="fas fa-trash mr-2"></i>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript -->
<script src="/js/main.js"></script>
<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>

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

View File

@@ -0,0 +1,358 @@
<!-- 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>
</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>
</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>
<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>
</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>
<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;
});
}
}
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;
fetch(`/admin/portfolio/${id}/toggle-publish`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.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;
} 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;
});
}
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');
items.forEach(item => {
const title = item.dataset.title;
if (title.includes(searchTerm)) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
updateProjectCount();
}
function filterByCategory() {
const selectedCategory = document.getElementById('categoryFilter').value;
const items = document.querySelectorAll('.portfolio-item');
items.forEach(item => {
const category = item.dataset.category;
if (!selectedCategory || category === selectedCategory) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
updateProjectCount();
}
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');
if (countElement) {
countElement.textContent = `Показано проектов: ${visibleItems} из ${totalItems}`;
}
}
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
document.getElementById('searchInput').addEventListener('keyup', function(event) {
if (event.key === 'Enter') {
searchProjects();
}
});
// Инициализация
document.addEventListener('DOMContentLoaded', function() {
updateProjectCount();
});
</script>

View File

@@ -0,0 +1,121 @@
<!-- Services 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">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<i class="fas fa-cog mr-2"></i>
Управление услугами
</h3>
<a href="/admin/services/add" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium">
<i class="fas fa-plus mr-1"></i>
Добавить услугу
</a>
</div>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul role="list" class="divide-y divide-gray-200">
<% if (services && services.length > 0) { %>
<% services.forEach(service => { %>
<li>
<div class="px-4 py-4 flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<div class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center">
<i class="<%= service.icon || 'fas fa-cog' %> text-blue-600"></i>
</div>
</div>
<div class="ml-4">
<div class="flex items-center">
<div class="text-sm font-medium text-gray-900"><%= service.name %></div>
<% if (service.featured) { %>
<span class="ml-2 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>
<% } %>
<% if (!service.isActive) { %>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Неактивна
</span>
<% } %>
</div>
<div class="text-sm text-gray-500">
<%= service.category %> •
<% if (service.pricing && service.pricing.basePrice) { %>
от $<%= service.pricing.basePrice %>
<% } %>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<a href="/services#<%= service.id %>" target="_blank" class="text-blue-600 hover:text-blue-900">
<i class="fas fa-external-link-alt"></i>
</a>
<a href="/admin/services/edit/<%= service.id %>" class="text-indigo-600 hover:text-indigo-900">
<i class="fas fa-edit"></i>
</a>
<button onclick="deleteService('<%= service.id %>')" class="text-red-600 hover:text-red-900">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</li>
<% }) %>
<% } else { %>
<li>
<div class="px-4 py-8 text-center">
<i class="fas fa-cog text-4xl text-gray-300 mb-4"></i>
<p class="text-gray-500">Услуги не найдены</p>
<a href="/admin/services/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>
<script>
function deleteService(id) {
if (confirm('Вы уверены, что хотите удалить эту услугу?')) {
fetch(`/api/admin/services/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Ошибка при удалении услуги');
}
})
.catch(error => {
console.error('Error:', error);
alert('Ошибка при удалении услуги');
});
}
}
</script>

350
views/admin/settings.ejs Normal file
View File

@@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Настройки сайта - SmartSolTech Admin</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/fixes.css">
</head>
<body class="bg-gray-100">
<!-- Admin Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold text-gray-900">
<i class="fas fa-cogs mr-2"></i>
SmartSolTech Admin
</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600">
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
</span>
<a href="/" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-external-link-alt mr-1"></i>
Посмотреть сайт
</a>
<form action="/admin/logout" method="post" class="inline">
<button type="submit" class="text-red-600 hover:text-red-800">
<i class="fas fa-sign-out-alt mr-1"></i>
Выход
</button>
</form>
</div>
</div>
</div>
</header>
<div class="flex">
<!-- Admin Sidebar -->
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
<nav class="mt-5 px-2">
<div class="space-y-1">
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-tachometer-alt mr-3"></i>
Панель управления
</a>
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-briefcase mr-3"></i>
Портфолио
</a>
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cog mr-3"></i>
Услуги
</a>
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-envelope mr-3"></i>
Сообщения
</a>
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-images mr-3"></i>
Медиа
</a>
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
<i class="fas fa-cogs mr-3"></i>
Настройки
</a>
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fab fa-telegram mr-3"></i>
Telegram Bot
</a>
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-paint-brush mr-3"></i>
Редактор баннеров
</a>
</div>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-8">
<div class="space-y-6">
<!-- Header -->
<div class="bg-white shadow rounded-lg p-6">
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-cogs mr-3 text-blue-600"></i>
Настройки сайта
</h1>
<p class="mt-2 text-gray-600">Управление основными параметрами сайта</p>
</div>
<!-- Site Settings -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<i class="fas fa-cogs mr-2"></i>
Настройки сайта
</h3>
</div>
<form id="settingsForm" class="p-6">
<div class="space-y-8">
<!-- Basic Settings -->
<div>
<h4 class="text-lg font-medium text-gray-900 mb-4">Основные настройки</h4>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="siteName" class="block text-sm font-medium text-gray-700">Название сайта</label>
<input type="text" name="siteName" id="siteName"
value="<%= settings.siteName || 'SmartSolTech' %>"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="sm:col-span-2">
<label for="siteDescription" class="block text-sm font-medium text-gray-700">Описание сайта</label>
<textarea name="siteDescription" id="siteDescription" rows="3"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"><%= settings.siteDescription || '' %></textarea>
</div>
<div>
<label for="logo" class="block text-sm font-medium text-gray-700">Логотип</label>
<input type="file" name="logo" id="logo" accept="image/*"
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
<% if (settings.logo) { %>
<img src="<%= settings.logo %>" alt="Current logo" class="mt-2 h-16 w-auto">
<% } %>
</div>
<div>
<label for="favicon" class="block text-sm font-medium text-gray-700">Favicon</label>
<input type="file" name="favicon" id="favicon" accept="image/*"
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
<% if (settings.favicon) { %>
<img src="<%= settings.favicon %>" alt="Current favicon" class="mt-2 h-8 w-8">
<% } %>
</div>
</div>
</div>
<!-- Contact Information -->
<div>
<h4 class="text-lg font-medium text-gray-900 mb-4">Контактная информация</h4>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label for="contactEmail" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="contact.email" id="contactEmail"
value="<%= settings.contact?.email || '' %>"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="contactPhone" class="block text-sm font-medium text-gray-700">Телефон</label>
<input type="tel" name="contact.phone" id="contactPhone"
value="<%= settings.contact?.phone || '' %>"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="sm:col-span-2">
<label for="contactAddress" class="block text-sm font-medium text-gray-700">Адрес</label>
<textarea name="contact.address" id="contactAddress" rows="2"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"><%= settings.contact?.address || '' %></textarea>
</div>
</div>
</div>
<!-- Social Media -->
<div>
<h4 class="text-lg font-medium text-gray-900 mb-4">Социальные сети</h4>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label for="socialFacebook" class="block text-sm font-medium text-gray-700">Facebook</label>
<input type="url" name="social.facebook" id="socialFacebook"
value="<%= settings.social?.facebook || '' %>"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="socialTwitter" class="block text-sm font-medium text-gray-700">Twitter</label>
<input type="url" name="social.twitter" id="socialTwitter"
value="<%= settings.social?.twitter || '' %>"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="socialInstagram" class="block text-sm font-medium text-gray-700">Instagram</label>
<input type="url" name="social.instagram" id="socialInstagram"
value="<%= settings.social?.instagram || '' %>"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="socialLinkedin" class="block text-sm font-medium text-gray-700">LinkedIn</label>
<input type="url" name="social.linkedin" id="socialLinkedin"
value="<%= settings.social?.linkedin || '' %>"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
</div>
<!-- Telegram Bot Settings -->
<div>
<h4 class="text-lg font-medium text-gray-900 mb-4">Telegram Bot</h4>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label for="telegramBotToken" class="block text-sm font-medium text-gray-700">Bot Token</label>
<input type="text" name="telegram.botToken" id="telegramBotToken"
value="<%= settings.telegram?.botToken || '' %>"
placeholder="123456789:AABBccDDeeFFggHHiiJJkkLLmmNNooP"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">Получите токен у @BotFather</p>
</div>
<div>
<label for="telegramChatId" class="block text-sm font-medium text-gray-700">Chat ID</label>
<input type="text" name="telegram.chatId" id="telegramChatId"
value="<%= settings.telegram?.chatId || '' %>"
placeholder="-123456789"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">ID чата для уведомлений</p>
</div>
<div class="sm:col-span-2">
<button type="button" id="testTelegram" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium">
<i class="fab fa-telegram-plane mr-1"></i>
Проверить соединение
</button>
<div id="telegramStatus" class="mt-2"></div>
</div>
</div>
</div>
<!-- SEO Settings -->
<div>
<h4 class="text-lg font-medium text-gray-900 mb-4">SEO настройки</h4>
<div class="space-y-4">
<div>
<label for="seoTitle" class="block text-sm font-medium text-gray-700">Meta Title</label>
<input type="text" name="seo.title" id="seoTitle"
value="<%= settings.seo?.title || '' %>"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="seoDescription" class="block text-sm font-medium text-gray-700">Meta Description</label>
<textarea name="seo.description" id="seoDescription" rows="3"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"><%= settings.seo?.description || '' %></textarea>
</div>
<div>
<label for="seoKeywords" class="block text-sm font-medium text-gray-700">Keywords</label>
<input type="text" name="seo.keywords" id="seoKeywords"
value="<%= settings.seo?.keywords || '' %>"
placeholder="веб-разработка, мобильные приложения, дизайн"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="mt-8 flex justify-end">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
<i class="fas fa-save mr-1"></i>
Сохранить настройки
</button>
</div>
</form>
</div>
<script>
document.getElementById('settingsForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
try {
const response = await fetch('/api/admin/settings', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
alert('Настройки успешно сохранены');
location.reload();
} else {
alert('Ошибка при сохранении настроек: ' + data.message);
}
} catch (error) {
console.error('Error:', error);
alert('Ошибка при сохранении настроек');
}
});
// Test Telegram connection
document.getElementById('testTelegram').addEventListener('click', async function() {
const botToken = document.getElementById('telegramBotToken').value;
const chatId = document.getElementById('telegramChatId').value;
const statusDiv = document.getElementById('telegramStatus');
if (!botToken || !chatId) {
statusDiv.innerHTML = '<p class="text-red-600">Пожалуйста, заполните Bot Token и Chat ID</p>';
return;
}
this.disabled = true;
this.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Проверка...';
try {
const response = await fetch('/api/admin/telegram/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ botToken, chatId })
});
const data = await response.json();
if (data.success) {
statusDiv.innerHTML = '<p class="text-green-600"><i class="fas fa-check mr-1"></i> Соединение установлено успешно!</p>';
} else {
statusDiv.innerHTML = `<p class="text-red-600"><i class="fas fa-times mr-1"></i> Ошибка: ${data.message}</p>`;
}
} catch (error) {
statusDiv.innerHTML = '<p class="text-red-600"><i class="fas fa-times mr-1"></i> Ошибка соединения</p>';
}
this.disabled = false;
this.innerHTML = '<i class="fab fa-telegram-plane mr-1"></i> Проверить соединение';
});
</script>
</div>
</main>
</div>
<!-- JavaScript -->
<script src="/js/main.js"></script>
</body>
</html>

885
views/admin/telegram.ejs Normal file
View File

@@ -0,0 +1,885 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Telegram Bot - SmartSolTech Admin</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/fixes.css">
</head>
<body class="bg-gray-100">
<!-- Admin Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold text-gray-900">
<i class="fas fa-cogs mr-2"></i>
SmartSolTech Admin
</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600">
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
</span>
<a href="/" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-external-link-alt mr-1"></i>
Посмотреть сайт
</a>
<form action="/admin/logout" method="post" class="inline">
<button type="submit" class="text-red-600 hover:text-red-800">
<i class="fas fa-sign-out-alt mr-1"></i>
Выход
</button>
</form>
</div>
</div>
</div>
</header>
<div class="flex">
<!-- Admin Sidebar -->
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
<nav class="mt-5 px-2">
<div class="space-y-1">
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-tachometer-alt mr-3"></i>
Панель управления
</a>
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-briefcase mr-3"></i>
Портфолио
</a>
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cog mr-3"></i>
Услуги
</a>
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-envelope mr-3"></i>
Сообщения
</a>
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-images mr-3"></i>
Медиа
</a>
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-cogs mr-3"></i>
Настройки
</a>
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
<i class="fab fa-telegram mr-3"></i>
Telegram Bot
</a>
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
<i class="fas fa-paint-brush mr-3"></i>
Редактор баннеров
</a>
</div>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-8">
<div class="space-y-6">
<!-- Header -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fab fa-telegram mr-3 text-blue-500"></i>
Telegram Bot
</h1>
<p class="mt-2 text-gray-600">Настройка и управление уведомлениями через Telegram</p>
</div>
<div class="flex items-center space-x-3">
<div class="flex items-center">
<div class="w-3 h-3 rounded-full <%= botConfigured ? 'bg-green-500' : 'bg-red-500' %> mr-2"></div>
<span class="text-sm font-medium <%= botConfigured ? 'text-green-700' : 'text-red-700' %>">
<%= botConfigured ? 'Подключен' : 'Не настроен' %>
</span>
</div>
</div>
</div>
</div>
<!-- Bot Configuration -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">
<i class="fas fa-cog mr-2 text-blue-500"></i>
Конфигурация бота
</h3>
<form id="bot-config-form" class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Bot Token -->
<div>
<label for="botToken" class="block text-sm font-medium text-gray-700 mb-2">
Токен бота *
</label>
<div class="relative">
<input type="password" id="botToken" name="botToken"
value="<%= botToken %>"
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
class="block w-full pr-10 border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="button" onclick="toggleTokenVisibility()"
class="absolute inset-y-0 right-0 pr-3 flex items-center">
<i id="token-eye" class="fas fa-eye text-gray-400"></i>
</button>
</div>
<p class="mt-1 text-sm text-gray-500">
Получите токен от <a href="https://t.me/BotFather" target="_blank" class="text-blue-600 underline">@BotFather</a>
</p>
</div>
<!-- Default Chat ID -->
<div>
<label for="chatId" class="block text-sm font-medium text-gray-700 mb-2">
ID чата по умолчанию
</label>
<input type="text" id="chatId" name="chatId"
value="<%= chatId %>"
placeholder="-1001234567890"
class="block w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">
Оставьте пустым, если будете выбирать чат из списка
</p>
</div>
</div>
<div class="flex space-x-3">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-save mr-2"></i>
Сохранить настройки
</button>
<button type="button" onclick="getBotInfo()" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-info-circle mr-2"></i>
Получить информацию о боте
</button>
</div>
</form>
<div id="config-result" class="mt-4 hidden"></div>
</div>
<% if (botConfigured) { %>
<!-- Bot Information -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-robot mr-2 text-green-500"></i>
Информация о боте
</h3>
<button onclick="refreshBotInfo()" class="text-blue-600 hover:text-blue-800 text-sm">
<i class="fas fa-refresh mr-1"></i>
Обновить
</button>
</div>
<div id="bot-info-container">
<% if (botInfo) { %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-600">Имя бота</div>
<div class="text-lg font-semibold text-gray-900">@<%= botInfo.username %></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-600">Отображаемое имя</div>
<div class="text-lg font-semibold text-gray-900"><%= botInfo.first_name %></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-600">ID бота</div>
<div class="text-lg font-semibold text-gray-900"><%= botInfo.id %></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-600">Может читать сообщения</div>
<div class="text-lg font-semibold <%= botInfo.can_read_all_group_messages ? 'text-green-600' : 'text-red-600' %>">
<%= botInfo.can_read_all_group_messages ? 'Да' : 'Нет' %>
</div>
</div>
</div>
<% } else { %>
<div class="text-center py-4 text-gray-500">
<i class="fas fa-robot text-4xl text-gray-300 mb-2"></i>
<p>Настройте токен бота для получения информации</p>
</div>
<% } %>
</div>
</div>
<!-- Available Chats -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-comments mr-2 text-blue-500"></i>
Доступные чаты
</h3>
<button onclick="discoverChats()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-sm transition-colors">
<i class="fas fa-search mr-1"></i>
Найти чаты
</button>
</div>
<div id="available-chats-container">
<% if (availableChats && availableChats.length > 0) { %>
<div class="grid gap-3">
<% availableChats.forEach(chat => { %>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<i class="fas <%= chat.type === 'group' || chat.type === 'supergroup' ? 'fa-users' : 'fa-user' %> text-blue-600 text-sm"></i>
</div>
<div>
<div class="font-medium text-gray-900"><%= chat.title %></div>
<div class="text-sm text-gray-500">
<%= chat.type %> • ID: <%= chat.id %>
<% if (chat.username) { %>• @<%= chat.username %><% } %>
</div>
</div>
</div>
<button onclick="selectChat('<%= chat.id %>', '<%= chat.title %>')"
class="text-blue-600 hover:text-blue-800 text-sm">
Выбрать
</button>
</div>
<% }); %>
</div>
<% } else { %>
<div class="text-center py-8 text-gray-500">
<i class="fas fa-comments text-4xl text-gray-300 mb-4"></i>
<p class="mb-2">Чаты не найдены</p>
<p class="text-sm">Отправьте боту сообщение или добавьте его в группу, затем нажмите "Найти чаты"</p>
</div>
<% } %>
</div>
</div>
<% } %>
<!-- Message Sender -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">
<i class="fas fa-paper-plane mr-2 text-purple-500"></i>
Отправить сообщение
</h3>
<form id="send-message-form" class="space-y-6">
<!-- Message Content -->
<div>
<label for="messageText" class="block text-sm font-medium text-gray-700 mb-2">
Текст сообщения *
</label>
<textarea id="messageText" name="message" rows="4" required
placeholder="Введите сообщение..."
class="block w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
</div>
<!-- Recipients -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Получатели
</label>
<div id="recipients-container" class="space-y-2">
<% if (availableChats && availableChats.length > 0) { %>
<% availableChats.forEach(chat => { %>
<label class="flex items-center">
<input type="checkbox" name="chatIds" value="<%= chat.id %>"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-900">
<%= chat.title %>
<span class="text-gray-500">(<%= chat.type %>)</span>
</span>
</label>
<% }); %>
<% } else { %>
<div class="text-sm text-gray-500">
<i class="fas fa-info-circle mr-1"></i>
Сообщение будет отправлено в чат по умолчанию
</div>
<% } %>
</div>
</div>
<!-- Message Options -->
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="text-sm font-medium text-gray-900 mb-3">Настройки сообщения</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label class="flex items-center">
<input type="checkbox" name="disableWebPagePreview"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-900">Отключить предпросмотр ссылок</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="disableNotification"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-900">Тихое уведомление</span>
</label>
</div>
<div class="mt-4">
<label for="parseMode" class="block text-sm font-medium text-gray-700 mb-1">
Формат текста
</label>
<select name="parseMode" id="parseMode"
class="block w-full border border-gray-300 rounded-md px-3 py-2 text-sm">
<option value="HTML">HTML</option>
<option value="Markdown">Markdown</option>
<option value="">Обычный текст</option>
</select>
</div>
</div>
<!-- Submit Buttons -->
<div class="flex justify-between">
<button type="button" onclick="previewMessage()"
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 class="space-x-3">
<button type="button" onclick="testConnection()"
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-vial mr-2"></i>
Тест соединения
</button>
<button type="submit" id="sendMessageBtn"
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">
<i class="fas fa-paper-plane mr-2"></i>
Отправить сообщение
</button>
</div>
</div>
</form>
<div id="send-result" class="mt-4 hidden"></div>
</div>
<!-- Bot Status and Controls -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Connection Test -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
<i class="fas fa-plug mr-2 text-green-500"></i>
Проверка подключения
</h3>
<p class="text-gray-600 mb-4">
Отправить тестовое сообщение для проверки работоспособности бота.
</p>
<button id="test-connection" class="w-full bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-vial mr-2"></i>
Тестировать подключение
</button>
<div id="test-result" class="mt-4 hidden"></div>
</div>
<!-- Send Custom Message -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
<i class="fas fa-paper-plane mr-2 text-blue-500"></i>
Отправить сообщение
</h3>
<form id="send-message-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Сообщение
</label>
<textarea
id="custom-message"
rows="4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Введите текст сообщения..."></textarea>
</div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-send mr-2"></i>
Отправить
</button>
</form>
<div id="send-result" class="mt-4 hidden"></div>
</div>
</div>
<!-- Notification Settings -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">
<i class="fas fa-bell mr-2 text-purple-500"></i>
Настройки уведомлений
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Notification Types -->
<div>
<h4 class="font-medium text-gray-900 mb-4">Типы уведомлений</h4>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center">
<i class="fas fa-envelope mr-3 text-blue-500"></i>
<span class="text-sm text-gray-900">Новые обращения</span>
</div>
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center">
<i class="fas fa-calculator mr-3 text-green-500"></i>
<span class="text-sm text-gray-900">Расчеты стоимости</span>
</div>
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center">
<i class="fas fa-briefcase mr-3 text-purple-500"></i>
<span class="text-sm text-gray-900">Новые проекты</span>
</div>
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center">
<i class="fas fa-cog mr-3 text-orange-500"></i>
<span class="text-sm text-gray-900">Новые услуги</span>
</div>
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
</div>
</div>
<!-- Bot Information -->
<div>
<h4 class="font-medium text-gray-900 mb-4">Информация о боте</h4>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Статус:</span>
<span class="text-sm font-medium text-green-600">Активен</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Токен:</span>
<span class="text-sm font-mono text-gray-800">••••••••••</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Chat ID:</span>
<span class="text-sm font-mono text-gray-800">••••••••••</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Последнее уведомление:</span>
<span class="text-sm text-gray-600">Недавно</span>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Notifications -->
<div class="bg-white shadow rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">
<i class="fas fa-history mr-2 text-gray-500"></i>
Недавние уведомления
</h3>
<div class="text-center py-8 text-gray-500">
<i class="fas fa-inbox text-4xl text-gray-300 mb-4"></i>
<p>Уведомления будут отображаться здесь после отправки</p>
</div>
</div>
<% } %>
</div>
</main>
</div>
<!-- JavaScript -->
<script src="/js/main.js"></script>
<script>
// Bot Configuration
document.getElementById('bot-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
try {
showLoading('Сохранение настроек...');
const response = await fetch('/admin/telegram/configure', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showResult('config-result', result.message, 'success');
// Update bot info if available
if (result.botInfo) {
updateBotInfo(result.botInfo);
}
if (result.availableChats) {
updateAvailableChats(result.availableChats);
}
} else {
showResult('config-result', result.message, 'error');
}
} catch (error) {
showResult('config-result', 'Ошибка при сохранении настроек', 'error');
} finally {
hideLoading();
}
});
// Send message form
document.getElementById('send-message-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const message = formData.get('message');
const chatIds = formData.getAll('chatIds');
const parseMode = formData.get('parseMode');
const disableWebPagePreview = formData.has('disableWebPagePreview');
const disableNotification = formData.has('disableNotification');
if (!message.trim()) {
showResult('send-result', 'Введите текст сообщения', 'error');
return;
}
const data = {
message: message.trim(),
chatIds: chatIds.length > 0 ? chatIds : [],
parseMode,
disableWebPagePreview,
disableNotification
};
try {
const btn = document.getElementById('sendMessageBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Отправка...';
const response = await fetch('/admin/telegram/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showResult('send-result', result.message, 'success');
document.getElementById('messageText').value = '';
} else {
showResult('send-result', result.message, 'error');
}
} catch (error) {
showResult('send-result', 'Ошибка при отправке сообщения', 'error');
} finally {
const btn = document.getElementById('sendMessageBtn');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Отправить сообщение';
}
});
// Utility functions
function toggleTokenVisibility() {
const input = document.getElementById('botToken');
const eye = document.getElementById('token-eye');
if (input.type === 'password') {
input.type = 'text';
eye.className = 'fas fa-eye-slash text-gray-400';
} else {
input.type = 'password';
eye.className = 'fas fa-eye text-gray-400';
}
}
async function getBotInfo() {
try {
showLoading('Получение информации о боте...');
const response = await fetch('/admin/telegram/info');
const result = await response.json();
if (result.success) {
updateBotInfo(result.botInfo);
updateAvailableChats(result.availableChats);
showResult('config-result', 'Информация о боте обновлена', 'success');
} else {
showResult('config-result', result.message, 'error');
}
} catch (error) {
showResult('config-result', 'Ошибка при получении информации о боте', 'error');
} finally {
hideLoading();
}
}
async function refreshBotInfo() {
await getBotInfo();
}
async function discoverChats() {
try {
showLoading('Поиск доступных чатов...');
const response = await fetch('/admin/telegram/info');
const result = await response.json();
if (result.success && result.availableChats) {
updateAvailableChats(result.availableChats);
showNotification('Найдено чатов: ' + result.availableChats.length, 'success');
} else {
showNotification('Чаты не найдены', 'warning');
}
} catch (error) {
showNotification('Ошибка при поиске чатов', 'error');
} finally {
hideLoading();
}
}
function selectChat(chatId, chatTitle) {
document.getElementById('chatId').value = chatId;
showNotification(`Выбран чат: ${chatTitle}`, 'success');
}
async function testConnection() {
try {
showLoading('Тестирование соединения...');
const response = await fetch('/admin/telegram/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
showResult('send-result', 'Соединение успешно! Тестовое сообщение отправлено.', 'success');
} else {
showResult('send-result', result.message, 'error');
}
} catch (error) {
showResult('send-result', 'Ошибка при тестировании соединения', 'error');
} finally {
hideLoading();
}
}
function previewMessage() {
const message = document.getElementById('messageText').value;
const parseMode = document.getElementById('parseMode').value;
if (!message.trim()) {
showNotification('Введите текст сообщения для предпросмотра', 'warning');
return;
}
// Simple preview modal (you can enhance this)
const preview = window.open('', 'preview', 'width=400,height=300');
preview.document.write(`
<html>
<head><title>Предпросмотр сообщения</title></head>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h3>Предпросмотр сообщения (${parseMode || 'Обычный текст'})</h3>
<div style="border: 1px solid #ccc; padding: 10px; background: #f9f9f9;">
${parseMode === 'HTML' ? message : message.replace(/\n/g, '<br>')}
</div>
<button onclick="window.close()" style="margin-top: 10px;">Закрыть</button>
</body>
</html>
`);
}
// UI Helper functions
function updateBotInfo(botInfo) {
const container = document.getElementById('bot-info-container');
if (!botInfo) return;
container.innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-600">Имя бота</div>
<div class="text-lg font-semibold text-gray-900">@${botInfo.username}</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-600">Отображаемое имя</div>
<div class="text-lg font-semibold text-gray-900">${botInfo.first_name}</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-600">ID бота</div>
<div class="text-lg font-semibold text-gray-900">${botInfo.id}</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-600">Может читать сообщения</div>
<div class="text-lg font-semibold ${botInfo.can_read_all_group_messages ? 'text-green-600' : 'text-red-600'}">
${botInfo.can_read_all_group_messages ? 'Да' : 'Нет'}
</div>
</div>
</div>
`;
}
function updateAvailableChats(chats) {
const container = document.getElementById('available-chats-container');
if (!chats || chats.length === 0) {
container.innerHTML = `
<div class="text-center py-8 text-gray-500">
<i class="fas fa-comments text-4xl text-gray-300 mb-4"></i>
<p class="mb-2">Чаты не найдены</p>
<p class="text-sm">Отправьте боту сообщение или добавьте его в группу, затем нажмите "Найти чаты"</p>
</div>
`;
return;
}
const chatsHtml = chats.map(chat => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<i class="fas ${chat.type === 'group' || chat.type === 'supergroup' ? 'fa-users' : 'fa-user'} text-blue-600 text-sm"></i>
</div>
<div>
<div class="font-medium text-gray-900">${chat.title}</div>
<div class="text-sm text-gray-500">
${chat.type} • ID: ${chat.id}
${chat.username ? '• @' + chat.username : ''}
</div>
</div>
</div>
<button onclick="selectChat('${chat.id}', '${chat.title}')"
class="text-blue-600 hover:text-blue-800 text-sm">
Выбрать
</button>
</div>
`).join('');
container.innerHTML = `<div class="grid gap-3">${chatsHtml}</div>`;
// Also update recipients list
updateRecipientsList(chats);
}
function updateRecipientsList(chats) {
const container = document.getElementById('recipients-container');
if (!chats || chats.length === 0) {
container.innerHTML = `
<div class="text-sm text-gray-500">
<i class="fas fa-info-circle mr-1"></i>
Сообщение будет отправлено в чат по умолчанию
</div>
`;
return;
}
const recipientsHtml = chats.map(chat => `
<label class="flex items-center">
<input type="checkbox" name="chatIds" value="${chat.id}"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-900">
${chat.title}
<span class="text-gray-500">(${chat.type})</span>
</span>
</label>
`).join('');
container.innerHTML = recipientsHtml;
}
function showResult(elementId, message, type) {
const element = document.getElementById(elementId);
const className = type === 'success' ? 'bg-green-100 text-green-800' :
type === 'warning' ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800';
const icon = type === 'success' ? 'fa-check' :
type === 'warning' ? 'fa-exclamation-triangle' :
'fa-times';
element.className = `mt-4 p-3 rounded-lg ${className}`;
element.innerHTML = `<i class="fas ${icon} mr-2"></i>${message}`;
element.classList.remove('hidden');
setTimeout(() => {
element.classList.add('hidden');
}, 5000);
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg 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 'warning':
notification.classList.add('bg-yellow-600');
break;
default:
notification.classList.add('bg-blue-600');
}
notification.innerHTML = `
<div class="flex items-center">
<i class="fas ${type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : type === 'warning' ? 'fa-exclamation-triangle' : 'fa-info-circle'} mr-2"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.remove('translate-x-full');
}, 100);
setTimeout(() => {
notification.classList.add('translate-x-full');
setTimeout(() => {
if (document.body.contains(notification)) {
document.body.removeChild(notification);
}
}, 300);
}, 4000);
}
function showLoading(message) {
// Create or show loading overlay
let overlay = document.getElementById('loading-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'loading-overlay';
overlay.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
overlay.innerHTML = `
<div class="bg-white p-6 rounded-lg shadow-lg">
<div class="flex items-center">
<i class="fas fa-spinner fa-spin text-blue-600 text-xl mr-3"></i>
<span id="loading-message">${message}</span>
</div>
</div>
`;
document.body.appendChild(overlay);
} else {
document.getElementById('loading-message').textContent = message;
overlay.style.display = 'flex';
}
}
function hideLoading() {
const overlay = document.getElementById('loading-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
</script>
</body>
</html>

View File

@@ -19,7 +19,7 @@
<%- include('partials/navigation') %>
<!-- Calculator Header -->
<section class="relative bg-gradient-to-r from-blue-600 to-purple-600 dark:from-blue-800 dark:to-purple-800 py-20 mt-16 hero-section">
<section class="relative bg-gradient-to-r from-blue-600 to-purple-600 dark:from-blue-800 dark:to-purple-800 mt-16 hero-section-compact">
<div class="absolute inset-0 bg-black opacity-20"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">

View File

@@ -19,7 +19,7 @@
<%- include('partials/navigation') %>
<!-- Hero Section -->
<section class="relative bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 py-20 hero-section">
<section class="relative bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 hero-section-compact">
<div class="absolute inset-0 bg-black opacity-50 dark:opacity-70"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-5xl md:text-6xl font-bold text-white mb-6">

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="ko">
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>오류 - SmartSolTech</title>
<title><%= title || __('errors.title') %></title>
<!-- PWA -->
<meta name="theme-color" content="#3B82F6">
@@ -14,9 +14,11 @@
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="/css/main.css" rel="stylesheet">
<link href="/css/fixes.css" rel="stylesheet">
<link href="/css/dark-theme.css" rel="stylesheet">
</head>
<body class="bg-gray-50">
<%- include('partials/navigation') %>
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
<%- include('partials/navigation', { settings: settings || {}, currentPage: 'error' }) %>
<!-- Error Section -->
<section class="min-h-screen flex items-center justify-center py-20">
@@ -30,12 +32,12 @@
<!-- Error Title -->
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
<%= title || '오류가 발생했습니다' %>
<%= title || __('errors.default_title') %>
</h1>
<!-- Error Message -->
<p class="text-xl text-gray-600 mb-8 leading-relaxed">
<%= message || '요청을 처리하는 중 문제가 발생했습니다.' %>
<%= message || __('errors.default_message') %>
</p>
<!-- Action Buttons -->
@@ -43,37 +45,53 @@
<a href="/"
class="bg-blue-600 text-white px-8 py-3 rounded-full hover:bg-blue-700 transition-colors font-semibold">
<i class="fas fa-home mr-2"></i>
홈으로 돌아가기
<%= __('errors.back_home') %>
</a>
<button onclick="history.back()"
class="border-2 border-blue-600 text-blue-600 px-8 py-3 rounded-full hover:bg-blue-600 hover:text-white transition-colors font-semibold">
<i class="fas fa-arrow-left mr-2"></i>
이전 페이지로
<%= __('errors.go_back') %>
</button>
</div>
<!-- Help Section -->
<div class="mt-12 pt-8 border-t border-gray-200">
<h3 class="text-lg font-semibold text-gray-900 mb-4">도움이 필요하신가요?</h3>
<h3 class="text-lg font-semibold text-gray-900 mb-4"><%= __('errors.need_help') %></h3>
<p class="text-gray-600 mb-6">
문제가 지속되면 언제든지 저희에게 연락해 주세요.
<%= __('errors.help_message') %>
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact"
class="text-blue-600 hover:text-blue-700 font-semibold">
<i class="fas fa-envelope mr-2"></i>
문의하기
<%= __('errors.contact_support') %>
</a>
<% if (settings && settings.contact && settings.contact.email) { %>
<a href="mailto:<%= settings.contact.email %>"
class="text-blue-600 hover:text-blue-700 font-semibold">
<i class="fas fa-at mr-2"></i>
<%= settings.contact.email %>
</a>
<% } else { %>
<a href="mailto:info@smartsoltech.kr"
class="text-blue-600 hover:text-blue-700 font-semibold">
<i class="fas fa-at mr-2"></i>
info@smartsoltech.kr
</a>
<% } %>
<% if (settings && settings.contact && settings.contact.phone) { %>
<a href="tel:<%= settings.contact.phone %>"
class="text-blue-600 hover:text-blue-700 font-semibold">
<i class="fas fa-phone mr-2"></i>
<%= settings.contact.phone %>
</a>
<% } else { %>
<a href="tel:+82-10-1234-5678"
class="text-blue-600 hover:text-blue-700 font-semibold">
<i class="fas fa-phone mr-2"></i>
+82-10-1234-5678
</a>
<% } %>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%- __(title || 'meta.title') %></title>
<title><%- title || 'SmartSolTech - Innovative Technology Solutions' %></title>
<!-- SEO Meta Tags -->
<meta name="description" content="<%- __('meta.description') %>">
@@ -17,15 +17,39 @@
<!-- PWA -->
<meta name="theme-color" content="#3B82F6">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/images/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/images/icon-192x192.png">
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
<!-- Custom CSS (load first) -->
<link href="/css/base.css" rel="stylesheet">
<link href="/css/main.css" rel="stylesheet">
<link href="/css/fixes.css" rel="stylesheet">
<link href="/css/dark-theme.css" rel="stylesheet">
<!-- Tailwind CSS via CDN -->
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous">
<!-- AOS Animation Library -->
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
<!-- Tailwind Configuration -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'primary': '#3B82F6',
'secondary': '#8B5CF6',
'accent': '#10B981',
},
fontFamily: {
'sans': ['Inter', 'system-ui', 'sans-serif'],
}
}
}
}
</script>
</head>
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
<%- include('partials/navigation') %>
@@ -337,64 +361,34 @@
<!-- Scripts -->
<script src="https://unpkg.com/aos@2.3.1/dist/aos.js"></script>
<script src="/js/main.js"></script>
<!-- Initialize AOS -->
<script>
// Initialize AOS
AOS.init({
duration: 800,
once: true,
offset: 100
});
// Blob animation
const style = document.createElement('style');
style.textContent = `
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
`;
document.head.appendChild(style);
// Contact form handler
document.getElementById('quick-contact-form').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch('/contact', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('<%- __("contact.form.success") %>');
this.reset();
} else {
alert('<%- __("contact.form.error") %>');
}
})
.catch(error => {
console.error('Error:', error);
alert('<%- __("contact.form.error") %>');
});
});
// Theme initialization
document.addEventListener('DOMContentLoaded', function() {
const theme = localStorage.getItem('theme') || 'light';
document.documentElement.className = theme === 'dark' ? 'dark' : '';
if (typeof AOS !== 'undefined') {
AOS.init({
duration: 800,
easing: 'ease-in-out',
once: true
});
}
});
</script>
<!-- PWA Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered: ', registration);
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
</script>
</body>
</html>
</html>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="ko">
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -35,14 +35,39 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- CSS -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/css/main.css">
<!-- Tailwind CSS (newer version with CDN Play) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Animation Library -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/aos/2.3.4/aos.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<!-- AOS Animation Library -->
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
<!-- Custom CSS -->
<link href="/css/base.css" rel="stylesheet">
<link href="/css/main.css" rel="stylesheet">
<link href="/css/fixes.css" rel="stylesheet">
<!-- Tailwind Configuration -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'primary': '#3B82F6',
'secondary': '#8B5CF6',
'accent': '#10B981',
},
fontFamily: {
'sans': ['Inter', 'system-ui', 'sans-serif'],
}
}
}
}
</script>
</head>
<body class="bg-gray-50 font-inter">
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
<!-- Navigation -->
<%- include('partials/navigation') %>

View File

@@ -8,7 +8,7 @@
<span class="ml-2 text-xl font-bold">SmartSolTech</span>
</div>
<p class="text-gray-300 dark:text-gray-400 mb-4">
<%- __('footer.company.description') %>
<%= __('footer.company.description') %>
</p>
<!-- Social Links -->
@@ -70,34 +70,34 @@
<!-- Quick Links -->
<div>
<h3 class="text-lg font-semibold mb-4"><%- __('footer.links.title') %></h3>
<h3 class="text-lg font-semibold mb-4"><%= __('footer.links.title') %></h3>
<ul class="space-y-2">
<li><a href="/" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.home') %></a></li>
<li><a href="/about" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.about') %></a></li>
<li><a href="/services" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.services') %></a></li>
<li><a href="/portfolio" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.portfolio') %></a></li>
<li><a href="/calculator" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.calculator') %></a></li>
<li><a href="/" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.home') %></a></li>
<li><a href="/about" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.about') %></a></li>
<li><a href="/services" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.services') %></a></li>
<li><a href="/portfolio" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.portfolio') %></a></li>
<li><a href="/calculator" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.calculator') %></a></li>
</ul>
</div>
<!-- Contact Info -->
<div>
<h3 class="text-lg font-semibold mb-4"><%- __('footer.contact.title') %></h3>
<h3 class="text-lg font-semibold mb-4"><%= __('footer.contact.title') %></h3>
<ul class="space-y-2 text-gray-300 dark:text-gray-400">
<li class="flex items-center">
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<a href="mailto:<%= settings && settings.contact ? settings.contact.email : __('footer.contact.email') %>" class="hover:text-white dark:hover:text-gray-200 transition-colors">
<%= settings && settings.contact ? settings.contact.email : __('footer.contact.email') %>
<a href="mailto:<%= settings && settings.contact && settings.contact.email ? settings.contact.email : __('footer.contact.email') %>" class="hover:text-white dark:hover:text-gray-200 transition-colors">
<%= settings && settings.contact && settings.contact.email ? settings.contact.email : __('footer.contact.email') %>
</a>
</li>
<li class="flex items-center">
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path>
</svg>
<a href="tel:<%= settings && settings.contact ? settings.contact.phone : __('footer.contact.phone') %>" class="hover:text-white dark:hover:text-gray-200 transition-colors">
<%= settings && settings.contact ? settings.contact.phone : __('footer.contact.phone') %>
<a href="tel:<%= settings && settings.contact && settings.contact.phone ? settings.contact.phone : __('footer.contact.phone') %>" class="hover:text-white dark:hover:text-gray-200 transition-colors">
<%= settings && settings.contact && settings.contact.phone ? settings.contact.phone : __('footer.contact.phone') %>
</a>
</li>
<li class="flex items-start">
@@ -105,7 +105,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span><%= settings && settings.contact ? settings.contact.address : __('footer.contact.address') %></span>
<span><%= settings && settings.contact && settings.contact.address ? settings.contact.address : __('footer.contact.address') %></span>
</li>
</ul>
</div>
@@ -114,11 +114,11 @@
<!-- Bottom Section -->
<div class="border-t border-gray-800 dark:border-gray-700 mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
<p class="text-gray-300 dark:text-gray-400 text-sm">
<%- __('footer.copyright', { year: new Date().getFullYear() }) %>
<%= __('footer.copyright').replace('{{year}}', new Date().getFullYear()) %>
</p>
<div class="flex space-x-6 mt-4 md:mt-0">
<a href="/privacy" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 text-sm transition-colors"><%- __('footer.privacy') %></a>
<a href="/terms" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 text-sm transition-colors"><%- __('footer.terms') %></a>
<a href="/privacy" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 text-sm transition-colors"><%= __('footer.privacy') %></a>
<a href="/terms" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 text-sm transition-colors"><%= __('footer.terms') %></a>
</div>
</div>
</div>

View File

@@ -13,88 +13,69 @@
<div class="hidden md:flex items-center space-x-6">
<!-- Navigation Links -->
<a href="/" class="<%= currentPage === 'home' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
<%- __('navigation.home') %>
<%= __('navigation.home') %>
</a>
<a href="/about" class="<%= currentPage === 'about' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
<%- __('navigation.about') %>
<%= __('navigation.about') %>
</a>
<a href="/services" class="<%= currentPage === 'services' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
<%- __('navigation.services') %>
<%= __('navigation.services') %>
</a>
<a href="/portfolio" class="<%= currentPage === 'portfolio' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
<%- __('navigation.portfolio') %>
<%= __('navigation.portfolio') %>
</a>
<a href="/calculator" class="<%= currentPage === 'calculator' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
<%- __('navigation.calculator') %>
<%= __('navigation.calculator') %>
</a>
<a href="/contact" class="<%= currentPage === 'contact' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
<%- __('navigation.contact') %>
<%= __('navigation.contact') %>
</a>
<!-- Language Dropdown -->
<div class="relative group">
<button class="flex items-center text-gray-700 dark:text-gray-300 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors" id="language-dropdown">
<i class="fas fa-globe mr-2"></i>
<%- __('language.' + currentLanguage) %>
<%
let currentFlag = '🇰🇷';
if (currentLanguage === 'en') currentFlag = '🇺🇸';
else if (currentLanguage === 'ru') currentFlag = '🇷🇺';
else if (currentLanguage === 'kk') currentFlag = '🇰🇿';
%>
<span class="mr-2"><%= currentFlag %></span>
<%= __('language.' + currentLanguage) %>
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
<div class="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border dark:border-gray-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<a href="/lang/ko" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg">
🇰🇷 <%- __('language.korean') %>
🇰🇷 <%= __('language.korean') %>
</a>
<a href="/lang/en" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
🇺🇸 <%- __('language.english') %>
🇺🇸 <%= __('language.english') %>
</a>
<a href="/lang/ru" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
🇷🇺 <%- __('language.russian') %>
🇷🇺 <%= __('language.russian') %>
</a>
<a href="/lang/kk" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-b-lg">
🇰🇿 <%- __('language.kazakh') %>
🇰🇿 <%= __('language.kazakh') %>
</a>
</div>
</div>
<!-- Theme Toggle -->
<button id="theme-toggle" class="p-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 transition-colors" title="<%- __('theme.toggle') %>">
<i class="fas fa-sun dark:hidden"></i>
<i class="fas fa-moon hidden dark:block"></i>
</button>
<!-- CTA Button -->
<a href="/contact" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors btn-primary">
<%- __('hero.cta_primary') %>
</a>
<!-- iOS Style Theme Toggle -->
<div class="relative inline-block" title="<%= __('theme.toggle') %>">
<input type="checkbox" id="theme-toggle" class="sr-only">
<label for="theme-toggle" class="flex items-center cursor-pointer">
<div class="relative w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full transition-colors duration-200">
<div class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-md transform transition-transform duration-200 flex items-center justify-center theme-toggle-slider">
<i class="fas fa-sun text-yellow-500 text-xs theme-sun-icon"></i>
<i class="fas fa-moon text-blue-500 text-xs hidden theme-moon-icon"></i>
</div>
</div>
</label>
</div>
</div>
<!-- Mobile menu button -->
<div class="md:hidden flex items-center space-x-2">
<!-- Mobile Theme Toggle -->
<button id="mobile-theme-toggle" class="p-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 transition-colors">
<i class="fas fa-sun dark:hidden"></i>
<i class="fas fa-moon hidden dark:block"></i>
</button>
<!-- Mobile Language Toggle -->
<div class="relative">
<button id="mobile-language-toggle" class="p-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 transition-colors">
<i class="fas fa-globe"></i>
</button>
<div id="mobile-language-menu" class="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border dark:border-gray-700 hidden">
<a href="/lang/ko" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg">
🇰🇷 <%- __('language.korean') %>
</a>
<a href="/lang/en" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
🇺🇸 <%- __('language.english') %>
</a>
<a href="/lang/ru" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
🇷🇺 <%- __('language.russian') %>
</a>
<a href="/lang/kk" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-b-lg">
🇰🇿 <%- __('language.kazakh') %>
</a>
</div>
</div>
<!-- Hamburger Menu -->
<button id="mobile-menu-toggle" class="p-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -109,30 +90,23 @@
<div id="mobile-menu" class="md:hidden hidden bg-white dark:bg-gray-900 border-t dark:border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1">
<a href="/" class="<%= currentPage === 'home' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
<%- __('navigation.home') %>
<%= __('navigation.home') %>
</a>
<a href="/about" class="<%= currentPage === 'about' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
<%- __('navigation.about') %>
<%= __('navigation.about') %>
</a>
<a href="/services" class="<%= currentPage === 'services' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
<%- __('navigation.services') %>
<%= __('navigation.services') %>
</a>
<a href="/portfolio" class="<%= currentPage === 'portfolio' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
<%- __('navigation.portfolio') %>
<%= __('navigation.portfolio') %>
</a>
<a href="/calculator" class="<%= currentPage === 'calculator' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
<%- __('navigation.calculator') %>
<%= __('navigation.calculator') %>
</a>
<a href="/contact" class="<%= currentPage === 'contact' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
<%- __('navigation.contact') %>
<%= __('navigation.contact') %>
</a>
<!-- Mobile CTA -->
<div class="pt-4 pb-2">
<a href="/contact" class="block w-full bg-blue-600 text-white text-center px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
<%- __('hero.cta_primary') %>
</a>
</div>
</div>
</div>
</nav>
@@ -141,40 +115,53 @@
document.addEventListener('DOMContentLoaded', function() {
// Theme Management
const themeToggle = document.getElementById('theme-toggle');
const mobileThemeToggle = document.getElementById('mobile-theme-toggle');
const html = document.documentElement;
const slider = document.querySelector('.theme-toggle-slider');
const sunIcon = document.querySelector('.theme-sun-icon');
const moonIcon = document.querySelector('.theme-moon-icon');
// Get current theme
const currentTheme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
// Apply theme
if (currentTheme === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
// Theme toggle handlers
function toggleTheme() {
const isDark = html.classList.contains('dark');
// Apply theme and update toggle
function applyTheme(isDark) {
if (isDark) {
html.classList.remove('dark');
localStorage.setItem('theme', 'light');
fetch('/theme/light');
} else {
html.classList.add('dark');
localStorage.setItem('theme', 'dark');
fetch('/theme/dark');
themeToggle.checked = true;
if (slider) {
slider.style.transform = 'translateX(24px)';
slider.style.backgroundColor = '#374151';
}
if (sunIcon) sunIcon.classList.add('hidden');
if (moonIcon) moonIcon.classList.remove('hidden');
} else {
html.classList.remove('dark');
themeToggle.checked = false;
if (slider) {
slider.style.transform = 'translateX(0)';
slider.style.backgroundColor = '#ffffff';
}
if (sunIcon) sunIcon.classList.remove('hidden');
if (moonIcon) moonIcon.classList.add('hidden');
}
}
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
// Initial theme application
applyTheme(currentTheme === 'dark');
// Theme toggle handler
function toggleTheme() {
const isDark = html.classList.contains('dark');
const newTheme = isDark ? 'light' : 'dark';
applyTheme(!isDark);
localStorage.setItem('theme', newTheme);
fetch(`/theme/${newTheme}`);
}
if (mobileThemeToggle) {
mobileThemeToggle.addEventListener('click', toggleTheme);
if (themeToggle) {
themeToggle.addEventListener('change', toggleTheme);
}
// Mobile Menu Management
@@ -187,19 +174,22 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Mobile Language Menu
const mobileLanguageToggle = document.getElementById('mobile-language-toggle');
const mobileLanguageMenu = document.getElementById('mobile-language-menu');
// Desktop Language Dropdown Management
const languageDropdown = document.getElementById('language-dropdown');
const languageMenu = languageDropdown ? languageDropdown.nextElementSibling : null;
if (mobileLanguageToggle && mobileLanguageMenu) {
mobileLanguageToggle.addEventListener('click', function() {
mobileLanguageMenu.classList.toggle('hidden');
if (languageDropdown && languageMenu) {
languageDropdown.addEventListener('click', function(e) {
e.preventDefault();
languageMenu.classList.toggle('opacity-0');
languageMenu.classList.toggle('invisible');
});
// Close language menu when clicking outside
document.addEventListener('click', function(event) {
if (!mobileLanguageToggle.contains(event.target) && !mobileLanguageMenu.contains(event.target)) {
mobileLanguageMenu.classList.add('hidden');
if (!languageDropdown.contains(event.target) && !languageMenu.contains(event.target)) {
languageMenu.classList.add('opacity-0');
languageMenu.classList.add('invisible');
}
});
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="ko">
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -29,8 +29,9 @@
<link href="https://unpkg.com/swiper@8/swiper-bundle.min.css" rel="stylesheet">
<link href="/css/main.css" rel="stylesheet">
<link href="/css/fixes.css" rel="stylesheet">
<link href="/css/dark-theme.css" rel="stylesheet">
</head>
<body>
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
<%- include('partials/navigation') %>
<!-- Portfolio Header -->

View File

@@ -1,17 +1,17 @@
<!DOCTYPE html>
<html lang="ko">
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>포트폴리오 - SmartSolTech</title>
<title><%- __('portfolio.meta.title') %> - SmartSolTech</title>
<!-- SEO Meta Tags -->
<meta name="description" content="SmartSolTech의 다양한 프로젝트와 성공 사례를 확인해보세요. 웹 개발, 모바일 앱, UI/UX 디자인 포트폴리오.">
<meta name="keywords" content="포트폴리오, 웹 개발, 모바일 앱, UI/UX 디자인, 프로젝트, SmartSolTech">
<meta name="description" content="<%- __('portfolio.meta.description') %>">
<meta name="keywords" content="<%- __('portfolio.meta.keywords') %>">
<!-- Open Graph -->
<meta property="og:title" content="포트폴리오 - SmartSolTech">
<meta property="og:description" content="SmartSolTech의 다양한 프로젝트와 성공 사례">
<meta property="og:title" content="<%- __('portfolio.meta.og_title') %>">
<meta property="og:description" content="<%- __('portfolio.meta.og_description') %>">
<meta property="og:type" content="website">
<!-- PWA -->
@@ -24,31 +24,33 @@
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
<link href="/css/main.css" rel="stylesheet">
<link href="/css/fixes.css" rel="stylesheet">
<link href="/css/dark-theme.css" rel="stylesheet">
</head>
<body>
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
<%- include('partials/navigation') %>
<!-- Portfolio Hero Section -->
<section class="hero-section bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800 pt-20">
<section class="hero-section-compact bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800">
<div class="container mx-auto px-4 py-20 text-center text-white">
<h1 class="text-5xl md:text-6xl font-bold mb-6" data-aos="fade-up">
우리의 <span class="text-yellow-300">포트폴리오</span>
<%- __('portfolio_page.title') %>
</h1>
<p class="text-xl md:text-2xl mb-8 opacity-90" data-aos="fade-up" data-aos-delay="200">
혁신적인 프로젝트와 창의적인 솔루션들을 만나보세요
<%- __('portfolio_page.subtitle') %>
</p>
<div class="flex flex-wrap justify-center gap-4" data-aos="fade-up" data-aos-delay="400">
<button class="filter-btn bg-white text-blue-600 px-6 py-3 rounded-full font-semibold hover:bg-blue-50 transition-colors active" data-filter="all">
전체
<%- __('portfolio_page.categories.all') %>
</button>
<button class="filter-btn bg-blue-700 text-white px-6 py-3 rounded-full font-semibold hover:bg-blue-800 transition-colors" data-filter="web-development">
웹 개발
<%- __('portfolio_page.categories.web-development') %>
</button>
<button class="filter-btn bg-blue-700 text-white px-6 py-3 rounded-full font-semibold hover:bg-blue-800 transition-colors" data-filter="mobile-app">
모바일 앱
<%- __('portfolio_page.categories.mobile-app') %>
</button>
<button class="filter-btn bg-blue-700 text-white px-6 py-3 rounded-full font-semibold hover:bg-blue-800 transition-colors" data-filter="ui-ux-design">
UI/UX 디자인
<%- __('portfolio_page.categories.ui-ux-design') %>
</button>
</div>
</div>
@@ -88,7 +90,7 @@
<% if (item.featured) { %>
<div class="absolute top-4 right-4">
<span class="bg-yellow-500 text-white px-2 py-1 rounded-full text-xs font-bold">
<i class="fas fa-star"></i> FEATURED
<i class="fas fa-star"></i> <%- __('portfolio_page.labels.featured') %>
</span>
</div>
<% } %>
@@ -96,7 +98,7 @@
<!-- Overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<a href="/portfolio/<%= item._id %>" class="bg-white text-gray-900 px-6 py-3 rounded-full font-semibold hover:bg-gray-100 transition-colors">
자세히 보기
<%- __('portfolio_page.buttons.details') %>
</a>
</div>
</div>
@@ -149,7 +151,7 @@
<!-- Action Button -->
<div class="mt-4 pt-4 border-t border-gray-100">
<a href="/portfolio/<%= item._id %>" class="block w-full bg-blue-600 text-white text-center py-2 rounded-lg hover:bg-blue-700 transition-colors font-semibold">
프로젝트 상세보기
<%- __('portfolio_page.buttons.projectDetails') %>
</a>
</div>
</div>
@@ -158,8 +160,8 @@
<% } else { %>
<div class="col-span-full text-center py-12">
<i class="fas fa-folder-open text-6xl text-gray-300 mb-4"></i>
<h3 class="text-2xl font-bold text-gray-500 mb-2">아직 포트폴리오가 없습니다</h3>
<p class="text-gray-400">곧 멋진 프로젝트들을 공개할 예정입니다!</p>
<h3 class="text-2xl font-bold text-gray-500 mb-2"><%- __('portfolio_page.empty.title') %></h3>
<p class="text-gray-400"><%- __('portfolio_page.empty.subtitle') %></p>
</div>
<% } %>
</div>
@@ -168,7 +170,7 @@
<% if (portfolioItems && portfolioItems.length >= 9) { %>
<div class="text-center mt-12" data-aos="fade-up">
<button id="load-more-btn" class="bg-blue-600 text-white px-8 py-3 rounded-full hover:bg-blue-700 transition-colors font-semibold">
더 많은 프로젝트 보기
<%- __('portfolio_page.buttons.loadMore') %>
</button>
</div>
<% } %>
@@ -179,17 +181,17 @@
<section class="section-padding bg-gradient-to-r from-blue-600 to-purple-600 text-white">
<div class="container mx-auto px-4 text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-6" data-aos="fade-up">
다음 프로젝트의 주인공이 되어보세요
<%- __('portfolio_page.cta.title') %>
</h2>
<p class="text-xl mb-8 opacity-90" data-aos="fade-up" data-aos-delay="200">
우리와 함께 혁신적인 디지털 솔루션을 만들어보세요
<%- __('portfolio_page.cta.subtitle') %>
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center" data-aos="fade-up" data-aos-delay="400">
<a href="/contact" class="bg-white text-blue-600 px-8 py-3 rounded-full hover:bg-gray-100 transition-colors font-semibold">
프로젝트 문의하기
<%- __('portfolio_page.buttons.contact') %>
</a>
<a href="/calculator" class="border-2 border-white text-white px-8 py-3 rounded-full hover:bg-white hover:text-blue-600 transition-colors font-semibold">
비용 계산하기
<%- __('portfolio_page.buttons.calculate') %>
</a>
</div>
</div>
@@ -266,30 +268,30 @@
}
});
// Category name mapping
// Category name mapping - uses server-side localization
function getCategoryName(category) {
const categoryNames = {
'web-development': '웹 개발',
'mobile-app': '모바일 앱',
'ui-ux-design': 'UI/UX 디자인',
'branding': '브랜딩',
'marketing': '디지털 마케팅'
'web-development': '<%- __("portfolio_page.categories.web-development") %>',
'mobile-app': '<%- __("portfolio_page.categories.mobile-app") %>',
'ui-ux-design': '<%- __("portfolio_page.categories.ui-ux-design") %>',
'branding': '<%- __("portfolio_page.categories.branding") %>',
'marketing': '<%- __("portfolio_page.categories.marketing") %>'
};
return categoryNames[category] || category;
}
</script>
<%
// Helper function for category names
// Helper function for category names - uses i18n
function getCategoryName(category) {
const categoryNames = {
'web-development': '웹 개발',
'mobile-app': '모바일 앱',
'ui-ux-design': 'UI/UX 디자인',
'branding': '브랜딩',
'marketing': '디지털 마케팅'
const categoryMap = {
'web-development': 'portfolio_page.categories.web-development',
'mobile-app': 'portfolio_page.categories.mobile-app',
'ui-ux-design': 'portfolio_page.categories.ui-ux-design',
'branding': 'portfolio_page.categories.branding',
'marketing': 'portfolio_page.categories.marketing'
};
return categoryNames[category] || category;
return categoryMap[category] ? __(categoryMap[category]) : category;
}
%>
</body>

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="ko">
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>서비스 - SmartSolTech</title>
<title><%- __('services.meta.title') %> - SmartSolTech</title>
<!-- SEO Meta Tags -->
<meta name="description" content="SmartSolTech의 전문 서비스를 확인하세요. 웹 개발, 모바일 앱, UI/UX 디자인, 디지털 마케팅 등 다양한 기술 솔루션을 제공합니다.">
<meta name="keywords" content="웹 개발, 모바일 앱, UI/UX 디자인, 디지털 마케팅, 기술 솔루션, SmartSolTech">
<meta name="description" content="<%- __('services.meta.description') %>">
<meta name="keywords" content="<%- __('services.meta.keywords') %>">
<!-- PWA -->
<meta name="theme-color" content="#3B82F6">
@@ -19,18 +19,20 @@
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
<link href="/css/main.css" rel="stylesheet">
<link href="/css/fixes.css" rel="stylesheet">
<link href="/css/dark-theme.css" rel="stylesheet">
</head>
<body>
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
<%- include('partials/navigation') %>
<!-- Services Hero Section -->
<section class="hero-section bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800 pt-20">
<div class="container mx-auto px-4 py-20 text-center text-white">
<h1 class="text-5xl md:text-6xl font-bold mb-6" data-aos="fade-up">
우리의 <span class="text-yellow-300">서비스</span>
<!-- Services Hero Section - Компактный -->
<section class="hero-section-compact bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800">
<div class="container mx-auto px-4 py-16 text-center text-white">
<h1 class="text-4xl md:text-5xl font-bold mb-4" data-aos="fade-up">
<%- __('services.hero.title') %> <span class="text-yellow-300"><%- __('services.hero.title_highlight') %></span>
</h1>
<p class="text-xl md:text-2xl mb-8 opacity-90" data-aos="fade-up" data-aos-delay="200">
혁신적인 기술로 비즈니스의 성장을 지원합니다
<%- __('services.hero.subtitle') %>
</p>
</div>
</section>
@@ -62,9 +64,9 @@
<!-- Pricing -->
<% if (service.pricing) { %>
<div class="mb-6">
<div class="text-sm text-gray-500 mb-1">시작가격</div>
<div class="text-sm text-gray-500 mb-1"><%- __('services.cards.starting_price') %></div>
<div class="text-2xl font-bold text-blue-600">
<%= service.pricing.basePrice ? service.pricing.basePrice.toLocaleString() : '상담' %>
<%= service.pricing.basePrice ? service.pricing.basePrice.toLocaleString() : __('services.cards.consultation') %>
<% if (service.pricing.basePrice) { %>
<span class="text-sm text-gray-500">원~</span>
<% } %>
@@ -82,11 +84,11 @@
<div class="flex flex-col gap-3">
<a href="/contact?service=<%= service._id %>"
class="block w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white text-center py-3 rounded-lg font-semibold hover:from-blue-700 hover:to-purple-700 transition-all duration-300">
문의하기
<%- __('services.cards.contact') %>
</a>
<a href="/calculator?service=<%= service._id %>"
class="block w-full border-2 border-blue-600 text-blue-600 text-center py-3 rounded-lg font-semibold hover:bg-blue-600 hover:text-white transition-all duration-300">
비용 계산하기
<%- __('services.cards.calculate_cost') %>
</a>
</div>
@@ -94,7 +96,7 @@
<% if (service.featured) { %>
<div class="absolute top-4 right-4">
<span class="bg-yellow-500 text-white px-2 py-1 rounded-full text-xs font-bold">
<i class="fas fa-star"></i> 인기
<i class="fas fa-star"></i> <%- __('services.cards.popular') %>
</span>
</div>
<% } %>
@@ -103,8 +105,8 @@
<% } else { %>
<div class="col-span-full text-center py-12">
<i class="fas fa-cogs text-6xl text-gray-300 mb-4"></i>
<h3 class="text-2xl font-bold text-gray-500 mb-2">서비스 준비 중</h3>
<p class="text-gray-400">곧 다양한 서비스를 제공할 예정입니다!</p>
<h3 class="text-2xl font-bold text-gray-500 mb-2"><%- __('services.cards.coming_soon') %></h3>
<p class="text-gray-400"><%- __('services.cards.coming_soon_desc') %></p>
</div>
<% } %>
</div>
@@ -116,10 +118,10 @@
<div class="container mx-auto px-4">
<div class="text-center mb-16" data-aos="fade-up">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
프로젝트 진행 과정
<%- __('services.process.title') %>
</h2>
<p class="text-xl text-gray-600">
체계적이고 전문적인 프로세스로 프로젝트를 진행합니다
<p class="text-lg text-gray-600 mb-12 max-w-3xl mx-auto">
<%- __('services.process.subtitle') %>
</p>
</div>