fix: Исправлена функциональность создания и редактирования портфолио
- Добавлена поддержка загрузки изображений через multer - Исправлена обработка FormData в маршрутах создания и обновления портфолио - Добавлена обработка изображений с помощью Sharp (оптимизация в WebP) - Добавлен маршрут предпросмотра проекта - Исправлена валидация и парсинг технологий из JSON - Поддержка сохранения черновиков и публикации проектов
This commit is contained in:
276
routes/admin.js
276
routes/admin.js
@@ -1,8 +1,26 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { User, Portfolio, Service, Contact, SiteSettings, Banner } = require('../models');
|
const { User, Portfolio, Service, Contact, SiteSettings, Banner } = require('../models');
|
||||||
|
|
||||||
|
// Configure multer for file uploads
|
||||||
|
const storage = multer.memoryStorage(); // Use memory storage to process with sharp
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (file.mimetype.startsWith('image/')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files are allowed'), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Authentication middleware
|
// Authentication middleware
|
||||||
const requireAuth = (req, res, next) => {
|
const requireAuth = (req, res, next) => {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
@@ -536,23 +554,8 @@ router.get('/portfolio/add', requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create portfolio item
|
// Create portfolio item
|
||||||
router.post('/portfolio/add', requireAuth, [
|
router.post('/portfolio/add', requireAuth, upload.array('images', 10), async (req, res) => {
|
||||||
body('title').notEmpty().withMessage('제목을 입력해주세요'),
|
|
||||||
body('shortDescription').notEmpty().withMessage('간단한 설명을 입력해주세요'),
|
|
||||||
body('description').notEmpty().withMessage('자세한 설명을 입력해주세요'),
|
|
||||||
body('category').notEmpty().withMessage('카테고리를 선택해주세요'),
|
|
||||||
body('technologies').isArray({ min: 1 }).withMessage('최소 한 개의 기술을 입력해주세요'),
|
|
||||||
], async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '입력 데이터를 확인해주세요',
|
|
||||||
errors: errors.array()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
shortDescription,
|
shortDescription,
|
||||||
@@ -563,40 +566,101 @@ router.post('/portfolio/add', requireAuth, [
|
|||||||
githubUrl,
|
githubUrl,
|
||||||
clientName,
|
clientName,
|
||||||
duration,
|
duration,
|
||||||
isPublished = false,
|
seoTitle,
|
||||||
featured = false
|
seoDescription,
|
||||||
|
isPublished,
|
||||||
|
featured
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!title || !shortDescription || !description || !category) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Заполните все обязательные поля'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse technologies from JSON string
|
||||||
|
let parsedTechnologies = [];
|
||||||
|
try {
|
||||||
|
parsedTechnologies = JSON.parse(technologies || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
parsedTechnologies = typeof technologies === 'string' ? [technologies] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedTechnologies.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Добавьте хотя бы одну технологию'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process uploaded images
|
||||||
|
const processedImages = [];
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'portfolio');
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
try {
|
||||||
|
await fs.access(uploadDir);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of req.files) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const filename = `${timestamp}-${Math.random().toString(36).substr(2, 9)}.webp`;
|
||||||
|
const filepath = path.join(uploadDir, filename);
|
||||||
|
|
||||||
|
// Process image with Sharp
|
||||||
|
await sharp(file.buffer)
|
||||||
|
.webp({ quality: 85 })
|
||||||
|
.resize(1200, 800, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.toFile(filepath);
|
||||||
|
|
||||||
|
processedImages.push(`/uploads/portfolio/${filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SEO data
|
||||||
|
const seo = {};
|
||||||
|
if (seoTitle) seo.title = seoTitle;
|
||||||
|
if (seoDescription) seo.description = seoDescription;
|
||||||
|
|
||||||
|
// Create portfolio item
|
||||||
const portfolio = await Portfolio.create({
|
const portfolio = await Portfolio.create({
|
||||||
title,
|
title,
|
||||||
shortDescription,
|
shortDescription,
|
||||||
description,
|
description,
|
||||||
category,
|
category,
|
||||||
technologies: Array.isArray(technologies) ? technologies : technologies.split(',').map(t => t.trim()),
|
technologies: parsedTechnologies,
|
||||||
demoUrl: demoUrl || null,
|
images: processedImages,
|
||||||
|
projectUrl: demoUrl || null,
|
||||||
githubUrl: githubUrl || null,
|
githubUrl: githubUrl || null,
|
||||||
clientName: clientName || null,
|
clientName: clientName || null,
|
||||||
duration: duration ? parseInt(duration) : null,
|
duration: duration ? parseInt(duration) : null,
|
||||||
isPublished: Boolean(isPublished),
|
isPublished: isPublished === 'true' || isPublished === true,
|
||||||
featured: Boolean(featured),
|
featured: featured === 'true' || featured === true,
|
||||||
publishedAt: Boolean(isPublished) ? new Date() : null,
|
publishedAt: (isPublished === 'true' || isPublished === true) ? new Date() : null,
|
||||||
status: Boolean(isPublished) ? 'published' : 'draft'
|
status: (isPublished === 'true' || isPublished === true) ? 'published' : 'draft',
|
||||||
|
seo: Object.keys(seo).length > 0 ? seo : null
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '포트폴리오가 성공적으로 생성되었습니다',
|
message: 'Проект успешно создан!',
|
||||||
portfolio: {
|
portfolio: {
|
||||||
id: portfolio.id,
|
id: portfolio.id,
|
||||||
title: portfolio.title,
|
title: portfolio.title,
|
||||||
category: portfolio.category
|
category: portfolio.category,
|
||||||
|
isPublished: portfolio.isPublished
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Portfolio creation error:', error);
|
console.error('Portfolio creation error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '포트폴리오 생성 중 오류가 발생했습니다'
|
message: 'Ошибка при создании проекта: ' + error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -639,27 +703,13 @@ router.get('/portfolio/edit/:id', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update portfolio item
|
// Update portfolio item
|
||||||
router.put('/portfolio/:id', requireAuth, [
|
router.put('/portfolio/:id', requireAuth, upload.array('images', 10), async (req, res) => {
|
||||||
body('title').notEmpty().withMessage('제목을 입력해주세요'),
|
|
||||||
body('shortDescription').notEmpty().withMessage('간단한 설명을 입력해주세요'),
|
|
||||||
body('description').notEmpty().withMessage('자세한 설명을 입력해주세요'),
|
|
||||||
body('category').notEmpty().withMessage('카테고리를 선택해주세요')
|
|
||||||
], async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '입력 데이터를 확인해주세요',
|
|
||||||
errors: errors.array()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const portfolio = await Portfolio.findByPk(req.params.id);
|
const portfolio = await Portfolio.findByPk(req.params.id);
|
||||||
if (!portfolio) {
|
if (!portfolio) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '포트폴리오를 찾을 수 없습니다'
|
message: 'Проект не найден'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,41 +723,103 @@ router.put('/portfolio/:id', requireAuth, [
|
|||||||
githubUrl,
|
githubUrl,
|
||||||
clientName,
|
clientName,
|
||||||
duration,
|
duration,
|
||||||
|
seoTitle,
|
||||||
|
seoDescription,
|
||||||
isPublished,
|
isPublished,
|
||||||
featured
|
featured,
|
||||||
|
existingImages
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!title || !shortDescription || !description || !category) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Заполните все обязательные поля'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse technologies from JSON string
|
||||||
|
let parsedTechnologies = [];
|
||||||
|
try {
|
||||||
|
parsedTechnologies = JSON.parse(technologies || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
parsedTechnologies = typeof technologies === 'string' ? [technologies] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle existing images
|
||||||
|
let finalImages = [];
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(existingImages || '[]');
|
||||||
|
finalImages = Array.isArray(existing) ? existing : [];
|
||||||
|
} catch (e) {
|
||||||
|
finalImages = portfolio.images || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process new uploaded images
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'portfolio');
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
try {
|
||||||
|
await fs.access(uploadDir);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of req.files) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const filename = `${timestamp}-${Math.random().toString(36).substr(2, 9)}.webp`;
|
||||||
|
const filepath = path.join(uploadDir, filename);
|
||||||
|
|
||||||
|
// Process image with Sharp
|
||||||
|
await sharp(file.buffer)
|
||||||
|
.webp({ quality: 85 })
|
||||||
|
.resize(1200, 800, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.toFile(filepath);
|
||||||
|
|
||||||
|
finalImages.push(`/uploads/portfolio/${filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SEO data
|
||||||
|
const seo = portfolio.seo || {};
|
||||||
|
if (seoTitle) seo.title = seoTitle;
|
||||||
|
if (seoDescription) seo.description = seoDescription;
|
||||||
|
|
||||||
// Update portfolio
|
// Update portfolio
|
||||||
await portfolio.update({
|
await portfolio.update({
|
||||||
title,
|
title,
|
||||||
shortDescription,
|
shortDescription,
|
||||||
description,
|
description,
|
||||||
category,
|
category,
|
||||||
technologies: Array.isArray(technologies) ? technologies : technologies.split(',').map(t => t.trim()),
|
technologies: parsedTechnologies,
|
||||||
demoUrl: demoUrl || null,
|
images: finalImages,
|
||||||
|
projectUrl: demoUrl || null,
|
||||||
githubUrl: githubUrl || null,
|
githubUrl: githubUrl || null,
|
||||||
clientName: clientName || null,
|
clientName: clientName || null,
|
||||||
duration: duration ? parseInt(duration) : null,
|
duration: duration ? parseInt(duration) : null,
|
||||||
isPublished: Boolean(isPublished),
|
isPublished: isPublished === 'true' || isPublished === true,
|
||||||
featured: Boolean(featured),
|
featured: featured === 'true' || featured === true,
|
||||||
publishedAt: Boolean(isPublished) && !portfolio.publishedAt ? new Date() : portfolio.publishedAt,
|
publishedAt: (isPublished === 'true' || isPublished === true) && !portfolio.publishedAt ? new Date() : portfolio.publishedAt,
|
||||||
status: Boolean(isPublished) ? 'published' : 'draft'
|
status: (isPublished === 'true' || isPublished === true) ? 'published' : 'draft',
|
||||||
|
seo: Object.keys(seo).length > 0 ? seo : null
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '포트폴리오가 성공적으로 업데이트되었습니다',
|
message: 'Проект успешно обновлен!',
|
||||||
portfolio: {
|
portfolio: {
|
||||||
id: portfolio.id,
|
id: portfolio.id,
|
||||||
title: portfolio.title,
|
title: portfolio.title,
|
||||||
category: portfolio.category
|
category: portfolio.category,
|
||||||
|
isPublished: portfolio.isPublished
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Portfolio update error:', error);
|
console.error('Portfolio update error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '포트폴리오 업데이트 중 오류가 발생했습니다'
|
message: 'Ошибка при обновлении проекта: ' + error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -772,6 +884,64 @@ router.patch('/portfolio/:id/toggle-publish', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Portfolio preview
|
||||||
|
router.post('/portfolio/preview', requireAuth, upload.array('images', 10), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
shortDescription,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
technologies,
|
||||||
|
demoUrl,
|
||||||
|
githubUrl,
|
||||||
|
clientName
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Parse technologies from JSON string
|
||||||
|
let parsedTechnologies = [];
|
||||||
|
try {
|
||||||
|
parsedTechnologies = JSON.parse(technologies || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
parsedTechnologies = typeof technologies === 'string' ? [technologies] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process uploaded images for preview
|
||||||
|
const previewImages = [];
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
for (const file of req.files) {
|
||||||
|
// Convert to base64 for preview
|
||||||
|
const base64 = `data:${file.mimetype};base64,${file.buffer.toString('base64')}`;
|
||||||
|
previewImages.push(base64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewData = {
|
||||||
|
title,
|
||||||
|
shortDescription,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
technologies: parsedTechnologies,
|
||||||
|
images: previewImages,
|
||||||
|
projectUrl: demoUrl || null,
|
||||||
|
githubUrl: githubUrl || null,
|
||||||
|
clientName: clientName || null,
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
preview: previewData
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Portfolio preview error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при создании предпросмотра: ' + error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Services management
|
// Services management
|
||||||
router.get('/services', requireAuth, async (req, res) => {
|
router.get('/services', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user