fix: Исправлена функциональность создания и редактирования портфолио

- Добавлена поддержка загрузки изображений через multer
- Исправлена обработка FormData в маршрутах создания и обновления портфолио
- Добавлена обработка изображений с помощью Sharp (оптимизация в WebP)
- Добавлен маршрут предпросмотра проекта
- Исправлена валидация и парсинг технологий из JSON
- Поддержка сохранения черновиков и публикации проектов
This commit is contained in:
2025-10-22 21:18:24 +09:00
parent 43c660c01e
commit 46fad7ecc2

View File

@@ -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 {