From 46fad7ecc2b3bb5f056a81d21b5928298921fc25 Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Wed, 22 Oct 2025 21:18:24 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=80=D1=82=D1=84=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена поддержка загрузки изображений через multer - Исправлена обработка FormData в маршрутах создания и обновления портфолио - Добавлена обработка изображений с помощью Sharp (оптимизация в WebP) - Добавлен маршрут предпросмотра проекта - Исправлена валидация и парсинг технологий из JSON - Поддержка сохранения черновиков и публикации проектов --- routes/admin.js | 276 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 223 insertions(+), 53 deletions(-) diff --git a/routes/admin.js b/routes/admin.js index 52890fe..529109a 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -1,8 +1,26 @@ const express = require('express'); 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 { 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 const requireAuth = (req, res, next) => { if (!req.session.user) { @@ -536,23 +554,8 @@ router.get('/portfolio/add', requireAuth, (req, res) => { }); // Create portfolio item -router.post('/portfolio/add', requireAuth, [ - 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) => { +router.post('/portfolio/add', requireAuth, upload.array('images', 10), async (req, res) => { try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - message: '입력 데이터를 확인해주세요', - errors: errors.array() - }); - } - const { title, shortDescription, @@ -563,40 +566,101 @@ router.post('/portfolio/add', requireAuth, [ githubUrl, clientName, duration, - isPublished = false, - featured = false + seoTitle, + seoDescription, + isPublished, + featured } = 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({ title, shortDescription, description, category, - technologies: Array.isArray(technologies) ? technologies : technologies.split(',').map(t => t.trim()), - demoUrl: demoUrl || null, + technologies: parsedTechnologies, + images: processedImages, + projectUrl: demoUrl || null, githubUrl: githubUrl || null, clientName: clientName || null, duration: duration ? parseInt(duration) : null, - isPublished: Boolean(isPublished), - featured: Boolean(featured), - publishedAt: Boolean(isPublished) ? new Date() : null, - status: Boolean(isPublished) ? 'published' : 'draft' + isPublished: isPublished === 'true' || isPublished === true, + featured: featured === 'true' || featured === true, + publishedAt: (isPublished === 'true' || isPublished === true) ? new Date() : null, + status: (isPublished === 'true' || isPublished === true) ? 'published' : 'draft', + seo: Object.keys(seo).length > 0 ? seo : null }); res.json({ success: true, - message: '포트폴리오가 성공적으로 생성되었습니다', + message: 'Проект успешно создан!', portfolio: { id: portfolio.id, title: portfolio.title, - category: portfolio.category + category: portfolio.category, + isPublished: portfolio.isPublished } }); } catch (error) { console.error('Portfolio creation error:', error); res.status(500).json({ success: false, - message: '포트폴리오 생성 중 오류가 발생했습니다' + message: 'Ошибка при создании проекта: ' + error.message }); } }); @@ -639,27 +703,13 @@ router.get('/portfolio/edit/:id', requireAuth, async (req, res) => { }); // Update portfolio item -router.put('/portfolio/:id', requireAuth, [ - body('title').notEmpty().withMessage('제목을 입력해주세요'), - body('shortDescription').notEmpty().withMessage('간단한 설명을 입력해주세요'), - body('description').notEmpty().withMessage('자세한 설명을 입력해주세요'), - body('category').notEmpty().withMessage('카테고리를 선택해주세요') -], async (req, res) => { +router.put('/portfolio/:id', requireAuth, upload.array('images', 10), async (req, res) => { 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); if (!portfolio) { return res.status(404).json({ success: false, - message: '포트폴리오를 찾을 수 없습니다' + message: 'Проект не найден' }); } @@ -673,41 +723,103 @@ router.put('/portfolio/:id', requireAuth, [ githubUrl, clientName, duration, + seoTitle, + seoDescription, isPublished, - featured + featured, + existingImages } = 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 await portfolio.update({ title, shortDescription, description, category, - technologies: Array.isArray(technologies) ? technologies : technologies.split(',').map(t => t.trim()), - demoUrl: demoUrl || null, + technologies: parsedTechnologies, + images: finalImages, + projectUrl: demoUrl || null, githubUrl: githubUrl || null, clientName: clientName || null, duration: duration ? parseInt(duration) : null, - isPublished: Boolean(isPublished), - featured: Boolean(featured), - publishedAt: Boolean(isPublished) && !portfolio.publishedAt ? new Date() : portfolio.publishedAt, - status: Boolean(isPublished) ? 'published' : 'draft' + isPublished: isPublished === 'true' || isPublished === true, + featured: featured === 'true' || featured === true, + publishedAt: (isPublished === 'true' || isPublished === true) && !portfolio.publishedAt ? new Date() : portfolio.publishedAt, + status: (isPublished === 'true' || isPublished === true) ? 'published' : 'draft', + seo: Object.keys(seo).length > 0 ? seo : null }); res.json({ success: true, - message: '포트폴리오가 성공적으로 업데이트되었습니다', + message: 'Проект успешно обновлен!', portfolio: { id: portfolio.id, title: portfolio.title, - category: portfolio.category + category: portfolio.category, + isPublished: portfolio.isPublished } }); } catch (error) { console.error('Portfolio update error:', error); res.status(500).json({ 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 router.get('/services', requireAuth, async (req, res) => { try {