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

File diff suppressed because it is too large Load Diff

388
routes/api/admin.js Normal file
View File

@@ -0,0 +1,388 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const { Portfolio, Service, Contact, User } = require('../../models');
// Multer configuration for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'public/uploads/');
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed!'), false);
}
},
limits: {
fileSize: 10 * 1024 * 1024 // 10MB
}
});
// Authentication middleware
const requireAuth = (req, res, next) => {
if (!req.session.user) {
return res.status(401).json({ success: false, message: 'Authentication required' });
}
next();
};
// Portfolio API Routes
router.post('/portfolio', requireAuth, upload.array('images', 10), async (req, res) => {
try {
const {
title,
shortDescription,
description,
category,
clientName,
projectUrl,
githubUrl,
technologies,
featured,
isPublished
} = req.body;
// Process uploaded images
const images = req.files ? req.files.map((file, index) => ({
url: `/uploads/${file.filename}`,
alt: `${title} image ${index + 1}`,
isPrimary: index === 0
})) : [];
// Parse technologies
let techArray = [];
if (technologies) {
try {
techArray = JSON.parse(technologies);
} catch (e) {
techArray = technologies.split(',').map(t => t.trim());
}
}
const portfolio = await Portfolio.create({
title,
shortDescription,
description,
category,
clientName,
projectUrl: projectUrl || null,
githubUrl: githubUrl || null,
technologies: techArray,
images,
featured: featured === 'on',
isPublished: isPublished === 'on',
status: 'completed',
publishedAt: isPublished === 'on' ? new Date() : null
});
res.json({ success: true, portfolio });
} catch (error) {
console.error('Portfolio creation error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
router.patch('/portfolio/:id', requireAuth, upload.array('images', 10), async (req, res) => {
try {
const portfolio = await Portfolio.findByPk(req.params.id);
if (!portfolio) {
return res.status(404).json({ success: false, message: 'Portfolio not found' });
}
const updates = { ...req.body };
// Handle checkboxes
updates.featured = updates.featured === 'on';
updates.isPublished = updates.isPublished === 'on';
// Process technologies
if (updates.technologies) {
try {
updates.technologies = JSON.parse(updates.technologies);
} catch (e) {
updates.technologies = updates.technologies.split(',').map(t => t.trim());
}
}
// Process new images
if (req.files && req.files.length > 0) {
const newImages = req.files.map((file, index) => ({
url: `/uploads/${file.filename}`,
alt: `${updates.title || portfolio.title} image ${index + 1}`,
isPrimary: index === 0 && (!portfolio.images || portfolio.images.length === 0)
}));
updates.images = [...(portfolio.images || []), ...newImages];
}
await portfolio.update(updates);
res.json({ success: true, portfolio });
} catch (error) {
console.error('Portfolio update error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
router.delete('/portfolio/:id', requireAuth, async (req, res) => {
try {
const portfolio = await Portfolio.findByPk(req.params.id);
if (!portfolio) {
return res.status(404).json({ success: false, message: 'Portfolio not found' });
}
await portfolio.destroy();
res.json({ success: true });
} catch (error) {
console.error('Portfolio deletion error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// Services API Routes
router.post('/services', requireAuth, async (req, res) => {
try {
const {
name,
description,
shortDescription,
icon,
category,
features,
pricing,
estimatedTime,
isActive,
featured,
tags
} = req.body;
// Parse arrays
let featuresArray = [];
let tagsArray = [];
let pricingObj = {};
if (features) {
try {
featuresArray = JSON.parse(features);
} catch (e) {
featuresArray = features.split(',').map(f => f.trim());
}
}
if (tags) {
try {
tagsArray = JSON.parse(tags);
} catch (e) {
tagsArray = tags.split(',').map(t => t.trim());
}
}
if (pricing) {
try {
pricingObj = JSON.parse(pricing);
} catch (e) {
pricingObj = { basePrice: pricing };
}
}
const service = await Service.create({
name,
description,
shortDescription,
icon,
category,
features: featuresArray,
pricing: pricingObj,
estimatedTime,
isActive: isActive === 'on',
featured: featured === 'on',
tags: tagsArray
});
res.json({ success: true, service });
} catch (error) {
console.error('Service creation error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
router.delete('/services/:id', requireAuth, async (req, res) => {
try {
const service = await Service.findByPk(req.params.id);
if (!service) {
return res.status(404).json({ success: false, message: 'Service not found' });
}
await service.destroy();
res.json({ success: true });
} catch (error) {
console.error('Service deletion error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// Contacts API Routes
router.patch('/contacts/:id', requireAuth, async (req, res) => {
try {
const contact = await Contact.findByPk(req.params.id);
if (!contact) {
return res.status(404).json({ success: false, message: 'Contact not found' });
}
await contact.update(req.body);
res.json({ success: true, contact });
} catch (error) {
console.error('Contact update error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
router.delete('/contacts/:id', requireAuth, async (req, res) => {
try {
const contact = await Contact.findByPk(req.params.id);
if (!contact) {
return res.status(404).json({ success: false, message: 'Contact not found' });
}
await contact.destroy();
res.json({ success: true });
} catch (error) {
console.error('Contact deletion error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// Telegram notification for contact
router.post('/contacts/:id/telegram', requireAuth, async (req, res) => {
try {
const contact = await Contact.findByPk(req.params.id);
if (!contact) {
return res.status(404).json({ success: false, message: 'Contact not found' });
}
// Send Telegram notification
const telegramService = require('../../services/telegram');
const result = await telegramService.sendContactNotification(contact);
if (result.success) {
res.json({ success: true });
} else {
res.status(500).json({ success: false, message: result.message || result.error });
}
} catch (error) {
console.error('Telegram notification error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// Test Telegram connection
router.post('/telegram/test', requireAuth, async (req, res) => {
try {
const { botToken, chatId } = req.body;
// Temporarily set up telegram service with provided credentials
const axios = require('axios');
// Test bot info
const botResponse = await axios.get(`https://api.telegram.org/bot${botToken}/getMe`);
// Test sending a message
const testMessage = '✅ Telegram bot подключен успешно!\n\nЭто тестовое сообщение от SmartSolTech Admin Panel.';
await axios.post(`https://api.telegram.org/bot${botToken}/sendMessage`, {
chat_id: chatId,
text: testMessage,
parse_mode: 'Markdown'
});
res.json({
success: true,
bot: botResponse.data.result,
message: 'Test message sent successfully'
});
} catch (error) {
console.error('Telegram test error:', error);
let message = 'Connection failed';
if (error.response?.data?.description) {
message = error.response.data.description;
} else if (error.message) {
message = error.message;
}
res.status(400).json({ success: false, message });
}
});
// Settings API
const { SiteSettings } = require('../../models');
router.get('/settings', requireAuth, async (req, res) => {
try {
const settings = await SiteSettings.findOne() || {};
res.json({ success: true, settings });
} catch (error) {
console.error('Settings fetch error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
router.post('/settings', requireAuth, upload.fields([
{ name: 'logo', maxCount: 1 },
{ name: 'favicon', maxCount: 1 }
]), async (req, res) => {
try {
let settings = await SiteSettings.findOne();
if (!settings) {
settings = await SiteSettings.create({});
}
const updates = {};
// Handle nested objects
Object.keys(req.body).forEach(key => {
if (key.includes('.')) {
const [parent, child] = key.split('.');
if (!updates[parent]) updates[parent] = {};
updates[parent][child] = req.body[key];
} else {
updates[key] = req.body[key];
}
});
// Handle file uploads
if (req.files.logo) {
updates.logo = `/uploads/${req.files.logo[0].filename}`;
}
if (req.files.favicon) {
updates.favicon = `/uploads/${req.files.favicon[0].filename}`;
}
// Update existing settings with new values
Object.keys(updates).forEach(key => {
if (typeof updates[key] === 'object' && updates[key] !== null) {
settings[key] = { ...settings[key], ...updates[key] };
} else {
settings[key] = updates[key];
}
});
await settings.save();
res.json({ success: true, settings });
} catch (error) {
console.error('Settings update error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
module.exports = router;

View File

@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const User = require('../models/User');
const { User } = require('../models');
// Login validation rules
const loginValidation = [
@@ -25,7 +25,12 @@ router.post('/login', loginValidation, async (req, res) => {
const { email, password } = req.body;
// Find user
const user = await User.findOne({ email, isActive: true });
const user = await User.findOne({
where: {
email: email,
isActive: true
}
});
if (!user) {
return res.status(401).json({
success: false,
@@ -109,8 +114,9 @@ router.get('/me', async (req, res) => {
});
}
const user = await User.findById(req.session.user.id)
.select('-password');
const user = await User.findByPk(req.session.user.id, {
attributes: { exclude: ['password'] }
});
if (!user || !user.isActive) {
req.session.destroy();
@@ -163,7 +169,7 @@ router.put('/change-password', [
}
const { currentPassword, newPassword } = req.body;
const user = await User.findById(req.session.user.id);
const user = await User.findByPk(req.session.user.id);
if (!user) {
return res.status(404).json({

View File

@@ -1,13 +1,15 @@
const express = require('express');
const router = express.Router();
const Service = require('../models/Service');
const { Service } = require('../models');
// Get all services for calculator
router.get('/services', async (req, res) => {
try {
const services = await Service.find({ isActive: true })
.select('name pricing category features estimatedTime')
.sort({ category: 1, name: 1 });
const services = await Service.findAll({
where: { isActive: true },
attributes: ['id', 'name', 'pricing', 'category', 'features', 'estimatedTime'],
order: [['category', 'ASC'], ['name', 'ASC']]
});
const servicesByCategory = services.reduce((acc, service) => {
if (!acc[service.category]) {
@@ -50,9 +52,11 @@ router.post('/calculate', async (req, res) => {
}
// Get selected services details
const services = await Service.find({
_id: { $in: selectedServices },
isActive: true
const services = await Service.findAll({
where: {
id: selectedServices,
isActive: true
}
});
if (services.length !== selectedServices.length) {

View File

@@ -2,14 +2,8 @@ const express = require('express');
const router = express.Router();
const { body, validationResult } = require('express-validator');
const nodemailer = require('nodemailer');
const Contact = require('../models/Contact');
const TelegramBot = require('node-telegram-bot-api');
// Initialize Telegram bot if token is provided
let bot = null;
if (process.env.TELEGRAM_BOT_TOKEN) {
bot = new TelegramBot(process.env.TELEGRAM_BOT_TOKEN, { polling: false });
}
const { Contact } = require('../models');
const telegramService = require('../services/telegram');
// Contact form validation
const contactValidation = [
@@ -48,7 +42,7 @@ router.post('/submit', contactValidation, async (req, res) => {
await sendEmailNotification(contact);
// Send Telegram notification
await sendTelegramNotification(contact);
await telegramService.sendNewContactAlert(contact);
res.json({
success: true,
@@ -108,7 +102,10 @@ router.post('/estimate', [
// Send notifications
await sendEmailNotification(contact);
await sendTelegramNotification(contact);
await telegramService.sendCalculatorQuote({
...contactData,
services: services.map(s => ({ name: s, price: 0 })) // Simplified for now
});
res.json({
success: true,
@@ -170,42 +167,7 @@ async function sendEmailNotification(contact) {
}
}
// Helper function to send Telegram notification
async function sendTelegramNotification(contact) {
if (!bot || !process.env.TELEGRAM_CHAT_ID) {
console.log('Telegram configuration not provided, skipping Telegram notification');
return;
}
try {
const message = `
🔔 *New Contact Form Submission*
👤 *Name:* ${contact.name}
📧 *Email:* ${contact.email}
📱 *Phone:* ${contact.phone || 'Not provided'}
🏢 *Company:* ${contact.company || 'Not provided'}
📝 *Subject:* ${contact.subject}
💬 *Message:*
${contact.message}
📍 *Source:* ${contact.source}
🕐 *Time:* ${contact.createdAt.toLocaleString()}
[View in Admin Panel](${process.env.SITE_URL}/admin/contacts/${contact._id})
`;
await bot.sendMessage(process.env.TELEGRAM_CHAT_ID, message, {
parse_mode: 'Markdown',
disable_web_page_preview: true
});
console.log('Telegram notification sent successfully');
} catch (error) {
console.error('Telegram notification error:', error);
}
}
// Telegram notifications now handled by telegramService
// Helper function to calculate project estimate
function calculateProjectEstimate(services, projectType, timeline) {

View File

@@ -1,20 +1,23 @@
const express = require('express');
const router = express.Router();
const Portfolio = require('../models/Portfolio');
const Service = require('../models/Service');
const SiteSettings = require('../models/SiteSettings');
const { Portfolio, Service, SiteSettings } = require('../models');
const { Op } = require('sequelize');
// Home page
router.get('/', async (req, res) => {
try {
const [settings, featuredPortfolio, featuredServices] = await Promise.all([
SiteSettings.findOne() || {},
Portfolio.find({ featured: true, isPublished: true })
.sort({ order: 1, createdAt: -1 })
.limit(6),
Service.find({ featured: true, isActive: true })
.sort({ order: 1 })
.limit(4)
Portfolio.findAll({
where: { featured: true, isPublished: true },
order: [['order', 'ASC'], ['createdAt', 'DESC']],
limit: 6
}),
Service.findAll({
where: { featured: true, isActive: true },
order: [['order', 'ASC']],
limit: 4
})
]);
res.render('index', {
@@ -28,6 +31,7 @@ router.get('/', async (req, res) => {
console.error('Home page error:', error);
res.status(500).render('error', {
title: 'Error',
settings: {},
message: 'Something went wrong'
});
}
@@ -47,6 +51,7 @@ router.get('/about', async (req, res) => {
console.error('About page error:', error);
res.status(500).render('error', {
title: 'Error',
settings: {},
message: 'Something went wrong'
});
}
@@ -65,20 +70,28 @@ router.get('/portfolio', async (req, res) => {
query.category = category;
}
const [portfolio, total, categories] = await Promise.all([
Portfolio.find(query)
.sort({ featured: -1, publishedAt: -1 })
.skip(skip)
.limit(limit),
Portfolio.countDocuments(query),
Portfolio.distinct('category', { isPublished: true })
const [settings, portfolio, total, categories] = await Promise.all([
SiteSettings.findOne() || {},
Portfolio.findAll({
where: query,
order: [['featured', 'DESC'], ['publishedAt', 'DESC']],
offset: skip,
limit: limit
}),
Portfolio.count({ where: query }),
Portfolio.findAll({
where: { isPublished: true },
attributes: ['category'],
group: ['category']
}).then(results => results.map(r => r.category))
]);
const totalPages = Math.ceil(total / limit);
res.render('portfolio', {
title: 'Portfolio - SmartSolTech',
portfolio,
settings: settings || {},
portfolioItems: portfolio,
categories,
currentCategory: category || 'all',
pagination: {
@@ -93,6 +106,7 @@ router.get('/portfolio', async (req, res) => {
console.error('Portfolio page error:', error);
res.status(500).render('error', {
title: 'Error',
settings: {},
message: 'Something went wrong'
});
}
@@ -101,11 +115,15 @@ router.get('/portfolio', async (req, res) => {
// Portfolio detail page
router.get('/portfolio/:id', async (req, res) => {
try {
const portfolio = await Portfolio.findById(req.params.id);
const [settings, portfolio] = await Promise.all([
SiteSettings.findOne() || {},
Portfolio.findByPk(req.params.id)
]);
if (!portfolio || !portfolio.isPublished) {
return res.status(404).render('404', {
title: '404 - Project Not Found',
settings: settings || {},
message: 'The requested project was not found'
});
}
@@ -115,14 +133,19 @@ router.get('/portfolio/:id', async (req, res) => {
await portfolio.save();
// Get related projects
const relatedProjects = await Portfolio.find({
_id: { $ne: portfolio._id },
category: portfolio.category,
isPublished: true
}).limit(3);
const relatedProjects = await Portfolio.findAll({
where: {
id: { [Op.ne]: portfolio.id },
category: portfolio.category,
isPublished: true
},
order: [['publishedAt', 'DESC']],
limit: 3
});
res.render('portfolio-detail', {
title: `${portfolio.title} - Portfolio - SmartSolTech`,
settings: settings || {},
portfolio,
relatedProjects,
currentPage: 'portfolio'
@@ -131,6 +154,7 @@ router.get('/portfolio/:id', async (req, res) => {
console.error('Portfolio detail error:', error);
res.status(500).render('error', {
title: 'Error',
settings: {},
message: 'Something went wrong'
});
}
@@ -139,14 +163,22 @@ router.get('/portfolio/:id', async (req, res) => {
// Services page
router.get('/services', async (req, res) => {
try {
const services = await Service.find({ isActive: true })
.sort({ featured: -1, order: 1 })
.populate('portfolio', 'title images');
const categories = await Service.distinct('category', { isActive: true });
const [settings, services, categories] = await Promise.all([
SiteSettings.findOne() || {},
Service.findAll({
where: { isActive: true },
order: [['featured', 'DESC'], ['order', 'ASC']]
}),
Service.findAll({
where: { isActive: true },
attributes: ['category'],
group: ['category']
})
]);
res.render('services', {
title: 'Services - SmartSolTech',
settings: settings || {},
services,
categories,
currentPage: 'services'
@@ -155,6 +187,7 @@ router.get('/services', async (req, res) => {
console.error('Services page error:', error);
res.status(500).render('error', {
title: 'Error',
settings: {},
message: 'Something went wrong'
});
}
@@ -163,12 +196,18 @@ router.get('/services', async (req, res) => {
// Calculator page
router.get('/calculator', async (req, res) => {
try {
const services = await Service.find({ isActive: true })
.select('name pricing category')
.sort({ category: 1, name: 1 });
const [settings, services] = await Promise.all([
SiteSettings.findOne() || {},
Service.findAll({
where: { isActive: true },
attributes: ['id', 'name', 'pricing', 'category'],
order: [['category', 'ASC'], ['name', 'ASC']]
})
]);
res.render('calculator', {
title: 'Project Calculator - SmartSolTech',
settings: settings || {},
services,
currentPage: 'calculator'
});
@@ -176,6 +215,7 @@ router.get('/calculator', async (req, res) => {
console.error('Calculator page error:', error);
res.status(500).render('error', {
title: 'Error',
settings: {},
message: 'Something went wrong'
});
}
@@ -195,6 +235,7 @@ router.get('/contact', async (req, res) => {
console.error('Contact page error:', error);
res.status(500).render('error', {
title: 'Error',
settings: {},
message: 'Something went wrong'
});
}

View File

@@ -280,50 +280,210 @@ router.delete('/:filename', requireAuth, async (req, res) => {
}
});
// List uploaded images
// List uploaded images with advanced filtering and search
router.get('/list', requireAuth, async (req, res) => {
try {
const uploadPath = path.join(__dirname, '../public/uploads');
// Create uploads directory if it doesn't exist
try {
await fs.mkdir(uploadPath, { recursive: true });
} catch (mkdirError) {
// Directory might already exist, continue
}
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const limit = parseInt(req.query.limit) || 24;
const search = req.query.search?.toLowerCase() || '';
const sortBy = req.query.sortBy || 'date'; // date, name, size
const sortOrder = req.query.sortOrder || 'desc'; // asc, desc
const fileType = req.query.fileType || 'all'; // all, image, video, document
const files = await fs.readdir(uploadPath);
const imageFiles = files.filter(file =>
/\.(jpg|jpeg|png|gif|webp)$/i.test(file)
);
let files;
try {
files = await fs.readdir(uploadPath);
} catch (readdirError) {
if (readdirError.code === 'ENOENT') {
return res.json({
success: true,
images: [],
pagination: {
current: 1,
total: 0,
limit,
totalItems: 0,
hasNext: false,
hasPrev: false
},
filters: {
search: '',
sortBy: 'date',
sortOrder: 'desc',
fileType: 'all'
}
});
}
throw readdirError;
}
// Filter by file type
let filteredFiles = files;
if (fileType !== 'all') {
const typePatterns = {
image: /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?)$/i,
video: /\.(mp4|webm|avi|mov|mkv|wmv|flv)$/i,
document: /\.(pdf|doc|docx|txt|rtf|odt)$/i
};
const pattern = typePatterns[fileType];
if (pattern) {
filteredFiles = files.filter(file => pattern.test(file));
}
} else {
// Only show supported media files
filteredFiles = files.filter(file =>
/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?|mp4|webm|avi|mov|mkv|pdf|doc|docx)$/i.test(file)
);
}
const total = imageFiles.length;
const totalPages = Math.ceil(total / limit);
const start = (page - 1) * limit;
const end = start + limit;
const paginatedFiles = imageFiles.slice(start, end);
const imagesWithStats = await Promise.all(
paginatedFiles.map(async (file) => {
// Apply search filter
if (search) {
filteredFiles = filteredFiles.filter(file =>
file.toLowerCase().includes(search)
);
}
// Get file stats and create file objects
const filesWithStats = await Promise.all(
filteredFiles.map(async (file) => {
try {
const filePath = path.join(uploadPath, file);
const stats = await fs.stat(filePath);
return {
filename: file,
url: `/uploads/${file}`,
size: stats.size,
modified: stats.mtime,
isImage: true
};
return { file, stats, filePath };
} catch (error) {
console.error(`Error getting stats for ${file}:`, error);
return null;
}
})
);
const validImages = imagesWithStats.filter(img => img !== null);
const validFiles = filesWithStats.filter(item => item !== null);
// Sort files
validFiles.sort((a, b) => {
let aValue, bValue;
switch (sortBy) {
case 'name':
aValue = a.file.toLowerCase();
bValue = b.file.toLowerCase();
break;
case 'size':
aValue = a.stats.size;
bValue = b.stats.size;
break;
case 'date':
default:
aValue = a.stats.mtime;
bValue = b.stats.mtime;
break;
}
if (sortOrder === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
const total = validFiles.length;
const totalPages = Math.ceil(total / limit);
const start = (page - 1) * limit;
const end = start + limit;
const paginatedFiles = validFiles.slice(start, end);
const filesWithDetails = await Promise.all(
paginatedFiles.map(async ({ file, stats, filePath }) => {
try {
const ext = path.extname(file).toLowerCase();
let fileDetails = {
filename: file,
url: `/uploads/${file}`,
size: stats.size,
uploadedAt: stats.birthtime || stats.mtime,
modifiedAt: stats.mtime,
extension: ext,
isImage: false,
isVideo: false,
isDocument: false
};
// Determine file type and get additional info
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?)$/i.test(file)) {
fileDetails.isImage = true;
fileDetails.mimetype = `image/${ext.replace('.', '')}`;
// Get image dimensions
try {
const metadata = await sharp(filePath).metadata();
fileDetails.dimensions = {
width: metadata.width,
height: metadata.height
};
fileDetails.format = metadata.format;
} catch (sharpError) {
console.warn(`Could not get image metadata for ${file}`);
}
} else if (/\.(mp4|webm|avi|mov|mkv|wmv|flv)$/i.test(file)) {
fileDetails.isVideo = true;
fileDetails.mimetype = `video/${ext.replace('.', '')}`;
} else if (/\.(pdf|doc|docx|txt|rtf|odt)$/i.test(file)) {
fileDetails.isDocument = true;
fileDetails.mimetype = `application/${ext.replace('.', '')}`;
}
// Generate thumbnail for images
if (fileDetails.isImage && !file.includes('-thumbnail.')) {
const thumbnailPath = path.join(uploadPath, `${path.parse(file).name}-thumbnail.webp`);
try {
await fs.access(thumbnailPath);
fileDetails.thumbnail = `/uploads/${path.basename(thumbnailPath)}`;
} catch {
// Thumbnail doesn't exist, create it
try {
await sharp(filePath)
.resize(200, 150, {
fit: 'cover',
withoutEnlargement: false
})
.webp({ quality: 80 })
.toFile(thumbnailPath);
fileDetails.thumbnail = `/uploads/${path.basename(thumbnailPath)}`;
} catch (thumbError) {
console.warn(`Could not create thumbnail for ${file}`);
}
}
}
return fileDetails;
} catch (error) {
console.error(`Error getting details for ${file}:`, error);
return null;
}
})
);
const validMedia = filesWithDetails.filter(item => item !== null);
// Calculate storage stats
const totalSize = validFiles.reduce((sum, file) => sum + file.stats.size, 0);
const imageCount = validMedia.filter(f => f.isImage).length;
const videoCount = validMedia.filter(f => f.isVideo).length;
const documentCount = validMedia.filter(f => f.isDocument).length;
res.json({
success: true,
images: validImages,
files: validMedia,
pagination: {
current: page,
total: totalPages,
@@ -331,15 +491,243 @@ router.get('/list', requireAuth, async (req, res) => {
totalItems: total,
hasNext: page < totalPages,
hasPrev: page > 1
},
filters: {
search,
sortBy,
sortOrder,
fileType
},
stats: {
totalFiles: total,
totalSize,
imageCount,
videoCount,
documentCount,
formattedSize: formatFileSize(totalSize)
}
});
} catch (error) {
console.error('List images error:', error);
console.error('List media error:', error);
res.status(500).json({
success: false,
message: 'Error listing images'
message: 'Error listing media files'
});
}
});
// Create folder structure
router.post('/folder', requireAuth, async (req, res) => {
try {
const { folderName } = req.body;
if (!folderName || !folderName.trim()) {
return res.status(400).json({
success: false,
message: 'Folder name is required'
});
}
// Sanitize folder name
const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_]/g, '-');
const folderPath = path.join(__dirname, '../public/uploads', sanitizedName);
try {
await fs.mkdir(folderPath, { recursive: true });
res.json({
success: true,
message: 'Folder created successfully',
folderName: sanitizedName,
folderPath: `/uploads/${sanitizedName}`
});
} catch (error) {
if (error.code === 'EEXIST') {
return res.status(400).json({
success: false,
message: 'Folder already exists'
});
}
throw error;
}
} catch (error) {
console.error('Create folder error:', error);
res.status(500).json({
success: false,
message: 'Error creating folder'
});
}
});
// Get media file info
router.get('/info/:filename', requireAuth, async (req, res) => {
try {
const filename = req.params.filename;
// Security check
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({
success: false,
message: 'Invalid filename'
});
}
const filePath = path.join(__dirname, '../public/uploads', filename);
try {
const stats = await fs.stat(filePath);
const ext = path.extname(filename).toLowerCase();
let fileInfo = {
filename,
url: `/uploads/${filename}`,
size: stats.size,
formattedSize: formatFileSize(stats.size),
uploadedAt: stats.birthtime || stats.mtime,
modifiedAt: stats.mtime,
extension: ext,
mimetype: getMimeType(ext)
};
// Get additional info for images
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?)$/i.test(filename)) {
try {
const metadata = await sharp(filePath).metadata();
fileInfo.dimensions = {
width: metadata.width,
height: metadata.height
};
fileInfo.format = metadata.format;
fileInfo.hasAlpha = metadata.hasAlpha;
fileInfo.density = metadata.density;
} catch (sharpError) {
console.warn(`Could not get image metadata for ${filename}`);
}
}
res.json({
success: true,
fileInfo
});
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({
success: false,
message: 'File not found'
});
}
throw error;
}
} catch (error) {
console.error('Get file info error:', error);
res.status(500).json({
success: false,
message: 'Error getting file information'
});
}
});
// Resize image
router.post('/resize/:filename', requireAuth, async (req, res) => {
try {
const filename = req.params.filename;
const { width, height, quality = 85 } = req.body;
if (!width && !height) {
return res.status(400).json({
success: false,
message: 'Width or height must be specified'
});
}
// Security check
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({
success: false,
message: 'Invalid filename'
});
}
const originalPath = path.join(__dirname, '../public/uploads', filename);
const nameWithoutExt = path.parse(filename).name;
const resizedPath = path.join(
path.dirname(originalPath),
`${nameWithoutExt}-${width || 'auto'}x${height || 'auto'}.webp`
);
try {
let sharpInstance = sharp(originalPath);
if (width && height) {
sharpInstance = sharpInstance.resize(parseInt(width), parseInt(height), {
fit: 'cover'
});
} else if (width) {
sharpInstance = sharpInstance.resize(parseInt(width));
} else {
sharpInstance = sharpInstance.resize(null, parseInt(height));
}
await sharpInstance
.webp({ quality: parseInt(quality) })
.toFile(resizedPath);
res.json({
success: true,
message: 'Image resized successfully',
originalFile: filename,
resizedFile: path.basename(resizedPath),
resizedUrl: `/uploads/${path.basename(resizedPath)}`
});
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({
success: false,
message: 'Original file not found'
});
}
throw error;
}
} catch (error) {
console.error('Resize image error:', error);
res.status(500).json({
success: false,
message: 'Error resizing image'
});
}
});
// Utility functions
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function getMimeType(ext) {
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.tif': 'image/tiff',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.mkv': 'video/x-matroska',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
};
return mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
}
module.exports = router;

View File

@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const Portfolio = require('../models/Portfolio');
const { Portfolio } = require('../models');
const { Op } = require('sequelize');
// Get all portfolio items
router.get('/', async (req, res) => {
@@ -13,28 +14,37 @@ router.get('/', async (req, res) => {
const featured = req.query.featured;
// Build query
let query = { isPublished: true };
let whereClause = { isPublished: true };
if (category && category !== 'all') {
query.category = category;
whereClause.category = category;
}
if (featured === 'true') {
query.featured = true;
whereClause.featured = true;
}
if (search) {
query.$text = { $search: search };
whereClause = {
...whereClause,
[Op.or]: [
{ title: { [Op.iLike]: `%${search}%` } },
{ description: { [Op.iLike]: `%${search}%` } },
{ shortDescription: { [Op.iLike]: `%${search}%` } }
]
};
}
// Get portfolio items
const [portfolio, total] = await Promise.all([
Portfolio.find(query)
.sort({ featured: -1, publishedAt: -1 })
.skip(skip)
.limit(limit)
.select('title shortDescription category technologies images status publishedAt viewCount'),
Portfolio.countDocuments(query)
Portfolio.findAll({
where: whereClause,
order: [['featured', 'DESC'], ['publishedAt', 'DESC']],
offset: skip,
limit: limit,
attributes: ['id', 'title', 'shortDescription', 'category', 'technologies', 'images', 'status', 'publishedAt', 'viewCount']
}),
Portfolio.count({ where: whereClause })
]);
const totalPages = Math.ceil(total / limit);
@@ -63,7 +73,7 @@ router.get('/', async (req, res) => {
// Get single portfolio item
router.get('/:id', async (req, res) => {
try {
const portfolio = await Portfolio.findById(req.params.id);
const portfolio = await Portfolio.findByPk(req.params.id);
if (!portfolio || !portfolio.isPublished) {
return res.status(404).json({
@@ -77,13 +87,15 @@ router.get('/:id', async (req, res) => {
await portfolio.save();
// Get related projects
const relatedProjects = await Portfolio.find({
_id: { $ne: portfolio._id },
category: portfolio.category,
isPublished: true
})
.select('title shortDescription images')
.limit(4);
const relatedProjects = await Portfolio.findAll({
where: {
id: { [Op.ne]: portfolio.id },
category: portfolio.category,
isPublished: true
},
attributes: ['id', 'title', 'shortDescription', 'images'],
limit: 3
});
res.json({
success: true,
@@ -131,7 +143,7 @@ router.get('/meta/categories', async (req, res) => {
// Like portfolio item
router.post('/:id/like', async (req, res) => {
try {
const portfolio = await Portfolio.findById(req.params.id);
const portfolio = await Portfolio.findByPk(req.params.id);
if (!portfolio || !portfolio.isPublished) {
return res.status(404).json({
@@ -162,21 +174,22 @@ router.get('/search/:term', async (req, res) => {
const searchTerm = req.params.term;
const limit = parseInt(req.query.limit) || 10;
const portfolio = await Portfolio.find({
$and: [
{ isPublished: true },
{
$or: [
{ title: { $regex: searchTerm, $options: 'i' } },
{ description: { $regex: searchTerm, $options: 'i' } },
{ technologies: { $in: [new RegExp(searchTerm, 'i')] } }
]
}
]
})
.select('title shortDescription category images')
.sort({ featured: -1, publishedAt: -1 })
.limit(limit);
const portfolio = await Portfolio.findAll({
where: {
[Op.and]: [
{ isPublished: true },
{
[Op.or]: [
{ title: { [Op.iLike]: `%${searchTerm}%` } },
{ description: { [Op.iLike]: `%${searchTerm}%` } },
{ technologies: { [Op.contains]: [searchTerm] } }
]
}
]
},
attributes: ['id', 'title', 'shortDescription', 'images', 'category'],
limit: limit
});
res.json({
success: true,

View File

@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const Service = require('../models/Service');
const Portfolio = require('../models/Portfolio');
const { Service, Portfolio } = require('../models');
const { Op } = require('sequelize');
// Get all services
router.get('/', async (req, res) => {
@@ -9,19 +9,20 @@ router.get('/', async (req, res) => {
const category = req.query.category;
const featured = req.query.featured;
let query = { isActive: true };
let whereClause = { isActive: true };
if (category && category !== 'all') {
query.category = category;
whereClause.category = category;
}
if (featured === 'true') {
query.featured = true;
whereClause.featured = true;
}
const services = await Service.find(query)
.populate('portfolio', 'title images')
.sort({ featured: -1, order: 1 });
const services = await Service.findAll({
where: whereClause,
order: [['featured', 'DESC'], ['order', 'ASC']]
});
res.json({
success: true,
@@ -39,8 +40,7 @@ router.get('/', async (req, res) => {
// Get single service
router.get('/:id', async (req, res) => {
try {
const service = await Service.findById(req.params.id)
.populate('portfolio', 'title shortDescription images category');
const service = await Service.findByPk(req.params.id);
if (!service || !service.isActive) {
return res.status(404).json({
@@ -50,13 +50,14 @@ router.get('/:id', async (req, res) => {
}
// Get related services
const relatedServices = await Service.find({
_id: { $ne: service._id },
category: service.category,
isActive: true
})
.select('name shortDescription icon pricing')
.limit(3);
const relatedServices = await Service.findAll({
where: {
id: { [Op.ne]: service.id },
category: service.category,
isActive: true
},
limit: 3
});
res.json({
success: true,
@@ -107,21 +108,21 @@ router.get('/search/:term', async (req, res) => {
const searchTerm = req.params.term;
const limit = parseInt(req.query.limit) || 10;
const services = await Service.find({
$and: [
{ isActive: true },
{
$or: [
{ name: { $regex: searchTerm, $options: 'i' } },
{ description: { $regex: searchTerm, $options: 'i' } },
{ tags: { $in: [new RegExp(searchTerm, 'i')] } }
]
}
]
})
.select('name shortDescription icon pricing category')
.sort({ featured: -1, order: 1 })
.limit(limit);
const services = await Service.findAll({
where: {
[Op.and]: [
{ isActive: true },
{
[Op.or]: [
{ name: { [Op.iLike]: `%${searchTerm}%` } },
{ description: { [Op.iLike]: `%${searchTerm}%` } },
{ tags: { [Op.contains]: [searchTerm] } }
]
}
]
},
limit: limit
});
res.json({
success: true,