diff --git a/.gitignore b/.gitignore index 14b9aad..fc4bc00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # dependencies node_modules/ +/public # environment .env @@ -21,3 +22,4 @@ pids #cursor .cursor +package-lock.json \ No newline at end of file diff --git a/controllers/blogCategoryController.js b/controllers/blogCategoryController.js index 4043d22..52f5086 100644 --- a/controllers/blogCategoryController.js +++ b/controllers/blogCategoryController.js @@ -1,4 +1,5 @@ const BlogCategory = require('../models/blogCategory'); +const slugify = require('slugify'); // -------------------- Admin Controllers -------------------- @@ -49,12 +50,11 @@ exports.store = async (req, res) => { } = req.body; // Generate slug - const slug = name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim('-'); + const slug = slugify(name, { + lower: true, + strict: true, + locale: 'vi' + }); // Check if slug exists const existingCategory = await BlogCategory.findOne({ slug }); @@ -130,12 +130,11 @@ exports.update = async (req, res) => { category.isActive = isActive === 'on'; // Generate new slug if name changed - const newSlug = name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim('-'); + const newSlug = slugify(name, { + lower: true, + strict: true, + locale: 'vi' + }); if (newSlug !== category.slug) { const existingCategory = await BlogCategory.findOne({ @@ -166,6 +165,14 @@ exports.destroy = async (req, res) => { const category = await BlogCategory.findById(req.params.id); if (!category) { + // Check if it's an AJAX request + const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json'); + if (isAjax) { + return res.status(404).json({ + success: false, + message: 'Category not found' + }); + } req.flash('error_msg', 'Category not found'); return res.redirect('/admin/blog/categories'); } @@ -175,16 +182,44 @@ exports.destroy = async (req, res) => { const postCount = await Blog.countDocuments({ category: { $in: [category.name] } }); if (postCount > 0) { + // Check if it's an AJAX request + const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json'); + if (isAjax) { + return res.status(400).json({ + success: false, + message: 'Cannot delete category that has blog posts' + }); + } req.flash('error_msg', 'Cannot delete category that has blog posts'); return res.redirect('/admin/blog/categories'); } await BlogCategory.findByIdAndDelete(req.params.id); + // Check if it's an AJAX request + const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json'); + if (isAjax) { + return res.json({ + success: true, + message: 'Category deleted successfully' + }); + } + req.flash('success_msg', 'Category deleted successfully'); res.redirect('/admin/blog/categories'); } catch (err) { console.error('Category delete error:', err); + + // Check if it's an AJAX request + const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json'); + if (isAjax) { + return res.status(500).json({ + success: false, + message: 'Error deleting category', + error: err.message || 'Error deleting category' + }); + } + req.flash('error_msg', 'Error deleting category'); res.redirect('/admin/blog/categories'); } @@ -202,10 +237,18 @@ exports.api = async (req, res) => { await category.updatePostCount(); } - res.json(categories); + res.json({ + success: true, + message: 'Categories fetched successfully', + data: categories + }); } catch (err) { console.error('Categories API error:', err); - res.status(500).json({ error: 'Error loading categories' }); + res.status(500).json({ + success: false, + message: 'Error loading categories', + error: err.message || 'Error loading categories' + }); } }; @@ -218,13 +261,81 @@ exports.apiShow = async (req, res) => { }).lean(); if (!category) { - return res.status(404).json({ error: 'Category not found' }); + return res.status(404).json({ + success: false, + message: 'Category not found' + }); } - res.json(category); + res.json({ + success: true, + message: 'Category fetched successfully', + data: category + }); } catch (err) { console.error('Category show API error:', err); - res.status(500).json({ error: 'Error loading category' }); + res.status(500).json({ + success: false, + message: 'Error loading category', + error: err.message || 'Error loading category' + }); + } +}; + +// Quick create category (for inline creation in blog form) +exports.quickCreate = async (req, res) => { + try { + const { name, description } = req.body; + + if (!name || !name.trim()) { + return res.status(400).json({ + success: false, + message: 'Category name is required' + }); + } + + const categoryName = name.trim(); + + // Generate slug + const slug = slugify(categoryName, { + lower: true, + strict: true, + locale: 'vi' + }); + + // Check if category already exists + let category = await BlogCategory.findOne({ slug }); + + if (category) { + return res.json({ + success: true, + message: 'Category already exists', + data: category.toObject() + }); + } + + // Create new category + category = new BlogCategory({ + name: categoryName, + slug, + description: description || '', + isActive: true + }); + + await category.save(); + + res.json({ + success: true, + message: 'Category created successfully', + data: category.toObject() + }); + } catch (err) { + console.error('Quick create category error:', err); + res.status(500).json({ + success: false, + message: 'Error creating category', + error: err.message || 'Error creating category' + }); } }; diff --git a/controllers/blogController.js b/controllers/blogController.js index bc02bc4..22ce42b 100644 --- a/controllers/blogController.js +++ b/controllers/blogController.js @@ -4,17 +4,17 @@ const BlogTag = require('../models/blogTag'); const BlogComment = require('../models/blogComment'); const RecentPost = require('../models/recentPost'); const { addBaseUrlToImages } = require('../utils/imageHelper'); +const slugify = require('slugify'); // -------------------- Helper Functions -------------------- // Generate slug from title const generateSlug = (title) => { - return title - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim('-'); + return slugify(title, { + lower: true, + strict: true, + locale: 'vi' + }); }; // Update category post counts @@ -69,12 +69,15 @@ exports.index = async (req, res) => { // Get categories for filter const categories = await BlogCategory.getActive(); + + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; res.render('admin/blog/index', { layout: 'layouts/main', title: 'Blog Management', blogs, categories, + frontendUrl, pagination: { current: page, total: totalPages, @@ -98,13 +101,16 @@ exports.create = async (req, res) => { const categories = await BlogCategory.getActive(); const tags = await BlogTag.getActive(); + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; + res.render('admin/blog/create', { layout: 'layouts/main', title: 'Create New Blog Post', categories, tags, currentPath: req.path, - user: req.session.user + user: req.session.user, + frontendUrl }); } catch (err) { console.error('Blog create form error:', err); @@ -125,7 +131,9 @@ exports.store = async (req, res) => { status, isFeatured, author, - galleryImages + galleryImages, + quote, + contentAfterQuote } = req.body; // Generate slug @@ -149,12 +157,14 @@ exports.store = async (req, res) => { status: status || 'published', isFeatured: isFeatured === 'on', author: author || 'Admin', - galleryImages: galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : [] + galleryImages: galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : [], + quote: quote || '', + contentAfterQuote: contentAfterQuote || '' }; - // Handle featured image - if (req.file) { - blogData.featuredImage = `/uploads/blog/${req.file.filename}`; + // Handle featured image - using featuredImageUrl from form (uploaded via AJAX) + if (req.body.featuredImageUrl) { + blogData.featuredImage = req.body.featuredImageUrl; } // Create blog @@ -188,6 +198,8 @@ exports.edit = async (req, res) => { const categories = await BlogCategory.getActive(); const tags = await BlogTag.getActive(); + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; + res.render('admin/blog/edit', { layout: 'layouts/main', title: 'Edit Blog Post', @@ -195,7 +207,8 @@ exports.edit = async (req, res) => { categories, tags, currentPath: req.path, - user: req.session.user + user: req.session.user, + frontendUrl }); } catch (err) { console.error('Blog edit form error:', err); @@ -223,7 +236,9 @@ exports.update = async (req, res) => { status, isFeatured, author, - galleryImages + galleryImages, + quote, + contentAfterQuote } = req.body; // Update blog data @@ -236,10 +251,12 @@ exports.update = async (req, res) => { blog.isFeatured = isFeatured === 'on'; blog.author = author || 'Admin'; blog.galleryImages = galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : []; + blog.quote = quote || ''; + blog.contentAfterQuote = contentAfterQuote || ''; - // Handle featured image - if (req.file) { - blog.featuredImage = `/uploads/blog/${req.file.filename}`; + // Handle featured image - using featuredImageUrl from form (uploaded via AJAX) + if (req.body.featuredImageUrl) { + blog.featuredImage = req.body.featuredImageUrl; } // Generate new slug if title changed @@ -373,11 +390,30 @@ exports.apiShow = async (req, res) => { }); } - // Get comments for this post - const comments = await BlogComment.getApprovedByPost(blog._id); + // Get comments for this post (parent comments only) + const parentComments = await BlogComment.getApprovedByPost(blog._id); + + // Get replies for each parent comment + const commentsWithReplies = await Promise.all( + parentComments.map(async (parentComment) => { + const replies = await BlogComment.getReplies(parentComment._id); + return { + ...parentComment.toObject(), + replies: replies.map(reply => reply.toObject()) + }; + }) + ); + + // Flatten comments array (parent + replies) + const allComments = commentsWithReplies.flatMap(comment => [ + comment, + ...comment.replies + ]); // Add comments to blog - blog.comments = comments; + blog.comments = allComments; + // Keep commentsCount in sync for frontend + blog.commentsCount = allComments.length; // Add base URL to images const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; @@ -398,6 +434,72 @@ exports.apiShow = async (req, res) => { } }; +// Create a comment (no moderation for now: default approved) +exports.apiCreateComment = async (req, res) => { + try { + const { authorName, content, parentId } = req.body || {}; + + if (!authorName || !String(authorName).trim()) { + return res.status(400).json({ + success: false, + message: "authorName is required", + }); + } + + if (!content || !String(content).trim()) { + return res.status(400).json({ + success: false, + message: "content is required", + }); + } + + const blog = await Blog.findOne({ slug: req.params.slug, status: "published" }).lean(); + if (!blog) { + return res.status(404).json({ + success: false, + message: "Blog post not found", + }); + } + + // If replying, ensure parent exists and belongs to same post + let parentObjectId = null; + if (parentId) { + const parent = await BlogComment.findOne({ _id: parentId, postId: blog._id }).lean(); + if (!parent) { + return res.status(400).json({ + success: false, + message: "Invalid parentId", + }); + } + parentObjectId = parentId; + } + + const newComment = await BlogComment.create({ + postId: blog._id, + authorName: String(authorName).trim(), + content: String(content).trim(), + parentId: parentObjectId, + status: "approved", + }); + + // Keep counter roughly correct (also counts replies) + await Blog.updateOne({ _id: blog._id }, { $inc: { commentsCount: 1 } }); + + return res.json({ + success: true, + message: "Comment created successfully", + data: newComment.toJSON(), + }); + } catch (err) { + console.error("Create comment API error:", err); + return res.status(500).json({ + success: false, + message: "Error creating comment", + error: err.message || "Error creating comment", + }); + } +}; + // Get featured blogs exports.apiFeatured = async (req, res) => { try { @@ -408,7 +510,7 @@ exports.apiFeatured = async (req, res) => { .lean(); // Add base URL to images - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('port')}`; const processedBlogs = blogs.map(blog => addBaseUrlToImages(blog, baseUrl)); res.json({ diff --git a/controllers/blogTagController.js b/controllers/blogTagController.js index f4232ef..6daeb6b 100644 --- a/controllers/blogTagController.js +++ b/controllers/blogTagController.js @@ -1,4 +1,5 @@ const BlogTag = require('../models/blogTag'); +const slugify = require('slugify'); // -------------------- Admin Controllers -------------------- @@ -48,12 +49,11 @@ exports.store = async (req, res) => { } = req.body; // Generate slug - const slug = name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim('-'); + const slug = slugify(name, { + lower: true, + strict: true, + locale: 'vi' + }); // Check if slug exists const existingTag = await BlogTag.findOne({ slug }); @@ -126,12 +126,11 @@ exports.update = async (req, res) => { tag.isActive = isActive === 'on'; // Generate new slug if name changed - const newSlug = name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim('-'); + const newSlug = slugify(name, { + lower: true, + strict: true, + locale: 'vi' + }); if (newSlug !== tag.slug) { const existingTag = await BlogTag.findOne({ @@ -162,6 +161,14 @@ exports.destroy = async (req, res) => { const tag = await BlogTag.findById(req.params.id); if (!tag) { + // Check if it's an AJAX request + const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json'); + if (isAjax) { + return res.status(404).json({ + success: false, + message: 'Tag not found' + }); + } req.flash('error_msg', 'Tag not found'); return res.redirect('/admin/blog/tags'); } @@ -171,16 +178,44 @@ exports.destroy = async (req, res) => { const postCount = await Blog.countDocuments({ tags: { $in: [tag.name] } }); if (postCount > 0) { + // Check if it's an AJAX request + const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json'); + if (isAjax) { + return res.status(400).json({ + success: false, + message: 'Cannot delete tag that is used in blog posts' + }); + } req.flash('error_msg', 'Cannot delete tag that is used in blog posts'); return res.redirect('/admin/blog/tags'); } await BlogTag.findByIdAndDelete(req.params.id); + // Check if it's an AJAX request + const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json'); + if (isAjax) { + return res.json({ + success: true, + message: 'Tag deleted successfully' + }); + } + req.flash('success_msg', 'Tag deleted successfully'); res.redirect('/admin/blog/tags'); } catch (err) { console.error('Tag delete error:', err); + + // Check if it's an AJAX request + const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json'); + if (isAjax) { + return res.status(500).json({ + success: false, + message: 'Error deleting tag', + error: err.message || 'Error deleting tag' + }); + } + req.flash('error_msg', 'Error deleting tag'); res.redirect('/admin/blog/tags'); } @@ -198,10 +233,18 @@ exports.api = async (req, res) => { await tag.updatePostCount(); } - res.json(tags); + res.json({ + success: true, + message: 'Tags fetched successfully', + data: tags + }); } catch (err) { console.error('Tags API error:', err); - res.status(500).json({ error: 'Error loading tags' }); + res.status(500).json({ + success: false, + message: 'Error loading tags', + error: err.message || 'Error loading tags' + }); } }; @@ -210,10 +253,19 @@ exports.apiPopular = async (req, res) => { try { const limit = parseInt(req.query.limit) || 10; const tags = await BlogTag.getPopular(limit); - res.json(tags); + + res.json({ + success: true, + message: 'Popular tags fetched successfully', + data: tags + }); } catch (err) { console.error('Popular tags API error:', err); - res.status(500).json({ error: 'Error loading popular tags' }); + res.status(500).json({ + success: false, + message: 'Error loading popular tags', + error: err.message || 'Error loading popular tags' + }); } }; @@ -226,13 +278,80 @@ exports.apiShow = async (req, res) => { }).lean(); if (!tag) { - return res.status(404).json({ error: 'Tag not found' }); + return res.status(404).json({ + success: false, + message: 'Tag not found' + }); } - res.json(tag); + res.json({ + success: true, + message: 'Tag fetched successfully', + data: tag + }); } catch (err) { console.error('Tag show API error:', err); - res.status(500).json({ error: 'Error loading tag' }); + res.status(500).json({ + success: false, + message: 'Error loading tag', + error: err.message || 'Error loading tag' + }); + } +}; + +// Quick create tag (for inline creation in blog form) +exports.quickCreate = async (req, res) => { + try { + const { name } = req.body; + + if (!name || !name.trim()) { + return res.status(400).json({ + success: false, + message: 'Tag name is required' + }); + } + + const tagName = name.trim(); + + // Generate slug + const slug = slugify(tagName, { + lower: true, + strict: true, + locale: 'vi' + }); + + // Check if tag already exists + let tag = await BlogTag.findOne({ slug }); + + if (tag) { + return res.json({ + success: true, + message: 'Tag already exists', + data: tag.toObject() + }); + } + + // Create new tag + tag = new BlogTag({ + name: tagName, + slug, + isActive: true + }); + + await tag.save(); + + res.json({ + success: true, + message: 'Tag created successfully', + data: tag.toObject() + }); + } catch (err) { + console.error('Quick create tag error:', err); + res.status(500).json({ + success: false, + message: 'Error creating tag', + error: err.message || 'Error creating tag' + }); } }; diff --git a/data/blog.json b/data/blog.json index e04489c..4a6b1d2 100644 --- a/data/blog.json +++ b/data/blog.json @@ -50,43 +50,53 @@ "status": "published", "publishedAt": "11 March 2025", "isFeatured": true, - "featuredImage": "/uploads/blog/work-visa-canada-main.jpg", + "featuredImage": "/assets/img/inner-page/news-details/details-1.jpg", "galleryImages": [ - "/uploads/blog/work-visa-canada-1.jpg", - "/uploads/blog/work-visa-canada-2.jpg" + "/assets/img/inner-page/news-details/details-2.jpg", + "/assets/img/inner-page/news-details/details-3.jpg" ], + "quote": "This blog really helped me understand the difference between student and work visas. The explanations were clear and practical.", + "contentAfterQuote": "
It provides access to world-class universities, cultural exposure, and global networking opportunities. With a student visa, you may also get part-time work rights, which can help support your expenses and give you valuable international work experience. However, the primary focus remains on academics and personal growth. On the other hand, a work visa is perfect for those who want to establish themselves in a career overseas.
It provides immediate access to job markets, stable income, and often a pathway to permanent residency. Work visas are suitable for skilled professionals who are ready to contribute to the global workforce and achieve long-term career goals. Ultimately, the choice comes down to your personal aspirations. If education and exploration are your priorities, a student visa is ideal. If career advancement and stability are your goals, a work visa is the right fit.
", "commentsCount": 3 }, { "title": "Top 5 Scholarship Programs For International Students", "slug": "top-5-scholarship-programs-international-students", "excerpt": "Danh sách 5 chương trình học bổng nổi bật dành cho sinh viên quốc tế với mức hỗ trợ hấp dẫn.", - "content": "Nếu bạn đang tìm kiếm học bổng để giảm chi phí du học, đây là 5 chương trình bạn không nên bỏ qua. Mỗi chương trình đều có tiêu chí xét tuyển, mức hỗ trợ và thời hạn đăng ký khác nhau.
", + "content": "Nếu bạn đang tìm kiếm học bổng để giảm chi phí du học, đây là 5 chương trình bạn không nên bỏ qua. Mỗi chương trình đều có tiêu chí xét tuyển, mức hỗ trợ và thời hạn đăng ký khác nhau.
Học bổng là một trong những cách tốt nhất để giảm gánh nặng tài chính khi du học. Các chương trình học bổng không chỉ hỗ trợ về mặt tài chính mà còn mở ra nhiều cơ hội phát triển nghề nghiệp và mở rộng mạng lưới quan hệ quốc tế.
", "category": ["Study Abroad"], "tags": ["StudentVisa", "Scholarship"], "author": "Admin", "status": "published", "publishedAt": "20 March 2025", "isFeatured": false, - "featuredImage": "/uploads/blog/scholarship-programs-main.jpg", - "galleryImages": [], - "commentsCount": 0 + "featuredImage": "/assets/img/inner-page/news-details/details-1.jpg", + "galleryImages": [ + "/assets/img/inner-page/news-details/details-2.jpg", + "/assets/img/inner-page/news-details/details-3.jpg" + ], + "quote": "These scholarship programs opened doors I never thought possible. The application process was straightforward, and the support I received was incredible.", + "contentAfterQuote": "Applying for scholarships requires careful planning and preparation. Start by researching each program's requirements, deadlines, and eligibility criteria. Make sure to prepare all necessary documents well in advance, including transcripts, recommendation letters, and personal statements. Each scholarship has its own unique focus, so tailor your application to highlight how you align with their values and goals.
Remember that scholarship applications are competitive, so it's important to stand out. Showcase your academic achievements, extracurricular activities, and community involvement. Be authentic in your personal statement and demonstrate how the scholarship will help you achieve your educational and career aspirations. With dedication and proper preparation, you can increase your chances of securing financial support for your studies abroad.
", + "commentsCount": 2 }, { "title": "10 Travel Safety Tips You Should Know Before Flying", "slug": "10-travel-safety-tips-before-flying", "excerpt": "Những lưu ý quan trọng để đảm bảo an toàn cho chuyến bay và hành trình của bạn.", - "content": "An toàn luôn là ưu tiên hàng đầu khi đi du lịch. Dưới đây là 10 tips giúp bạn yên tâm hơn trên mọi chuyến đi, từ việc chuẩn bị giấy tờ, bảo hiểm đến cách bảo vệ tài sản cá nhân.
", + "content": "An toàn luôn là ưu tiên hàng đầu khi đi du lịch. Dưới đây là 10 tips giúp bạn yên tâm hơn trên mọi chuyến đi, từ việc chuẩn bị giấy tờ, bảo hiểm đến cách bảo vệ tài sản cá nhân.
Du lịch là một trải nghiệm tuyệt vời, nhưng điều quan trọng là phải chuẩn bị kỹ lưỡng để đảm bảo an toàn. Những tips này được đúc kết từ kinh nghiệm thực tế của nhiều du khách và sẽ giúp bạn tránh được những rủi ro không đáng có.
", "category": ["Travel Tips"], "tags": ["TravelSafety"], "author": "Admin", "status": "published", "publishedAt": "05 April 2025", "isFeatured": false, - "featuredImage": "/uploads/blog/travel-safety-main.jpg", + "featuredImage": "/assets/img/inner-page/news-details/details-1.jpg", "galleryImages": [ - "/uploads/blog/travel-safety-1.jpg" + "/assets/img/inner-page/news-details/details-2.jpg", + "/assets/img/inner-page/news-details/details-3.jpg" ], + "quote": "These safety tips saved me from potential problems during my trip. I especially appreciated the advice about travel insurance and document preparation.", + "contentAfterQuote": "Before you travel, make sure to research your destination thoroughly. Understand local customs, laws, and potential safety concerns. Keep copies of important documents like your passport, visa, and travel insurance in multiple places - both physical and digital. Inform family or friends about your itinerary and check in regularly during your trip.
When packing, prioritize essential items and keep valuables secure. Use luggage locks and consider travel insurance for expensive items. Stay aware of your surroundings, especially in crowded areas, and trust your instincts if something feels off. By following these safety tips, you can focus on enjoying your journey while staying protected throughout your travels.
", "commentsCount": 1 } ], @@ -94,19 +104,19 @@ { "title": "Ultimate Guide To Getting A Work Visa In Canada", "slug": "ultimate-guide-work-visa-canada", - "thumbnail": "/uploads/blog/work-visa-canada-main.jpg", + "thumbnail": "/assets/img/inner-page/news-details/post-1.jpg", "publishedAt": "11 March 2025" }, { "title": "Top 5 Scholarship Programs For International Students", "slug": "top-5-scholarship-programs-international-students", - "thumbnail": "/uploads/blog/scholarship-programs-main.jpg", + "thumbnail": "/assets/img/inner-page/news-details/post-2.jpg", "publishedAt": "20 March 2025" }, { "title": "10 Travel Safety Tips You Should Know Before Flying", "slug": "10-travel-safety-tips-before-flying", - "thumbnail": "/uploads/blog/travel-safety-main.jpg", + "thumbnail": "/assets/img/inner-page/news-details/post-3.jpg", "publishedAt": "05 April 2025" } ], @@ -129,6 +139,24 @@ "status": "approved", "parentAuthorName": "Frank Flores" }, + { + "postSlug": "top-5-scholarship-programs-international-students", + "authorName": "Sarah Johnson", + "authorAvatar": "/assets/img/inner-page/news-details/comment-1.png", + "content": "Cảm ơn bạn đã chia sẻ thông tin về các chương trình học bổng này. Mình đã apply và đang chờ kết quả!", + "createdAt": "March 15, 2025", + "status": "approved", + "parentAuthorName": null + }, + { + "postSlug": "top-5-scholarship-programs-international-students", + "authorName": "Michael Chen", + "authorAvatar": "/assets/img/inner-page/news-details/comment-2.png", + "content": "Bài viết rất chi tiết và hữu ích. Mình đã tìm thấy một chương trình phù hợp với mình.", + "createdAt": "March 18, 2025", + "status": "approved", + "parentAuthorName": null + }, { "postSlug": "10-travel-safety-tips-before-flying", "authorName": "Jenny Wilson", diff --git a/models/blog.js b/models/blog.js index 39aea69..89bf9bb 100644 --- a/models/blog.js +++ b/models/blog.js @@ -71,6 +71,20 @@ const blogSchema = new mongoose.Schema({ commentsCount: { type: Number, default: 0 + }, + + // Quote/Sidebar section + quote: { + type: String, + default: '', + trim: true + }, + + // Content after quote + contentAfterQuote: { + type: String, + default: '', + trim: true } }, { timestamps: true diff --git a/routes/admin.js b/routes/admin.js index fb7d1ff..b14f98c 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -478,6 +478,7 @@ router.post("/blog/categories/create", ensureAuthenticated, blogCategoryControll router.get("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.edit); router.post("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.update); router.post("/blog/categories/:id/delete", ensureAuthenticated, blogCategoryController.destroy); +router.post("/blog/categories/quick-create", ensureAuthenticated, blogCategoryController.quickCreate); // Blog Tags Management router.get("/blog/tags", ensureAuthenticated, blogTagController.index); @@ -486,5 +487,6 @@ router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store); router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit); router.post("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.update); router.post("/blog/tags/:id/delete", ensureAuthenticated, blogTagController.destroy); +router.post("/blog/tags/quick-create", ensureAuthenticated, blogTagController.quickCreate); module.exports = router; diff --git a/routes/index.js b/routes/index.js index 3322df9..7b58529 100644 --- a/routes/index.js +++ b/routes/index.js @@ -168,6 +168,9 @@ router.get("/api/blog/tags/:slug", blogTagController.apiShow); router.get("/api/blog/:id/categories", blogController.apiCategories); router.get("/api/blog/:id/tags", blogController.apiTags); +// Blog comments (must come before /api/blog/:slug) +router.post("/api/blog/:slug/comments", blogController.apiCreateComment); + // Blog detail by slug (must come last among blog routes) router.get("/api/blog/:slug", blogController.apiShow); diff --git a/server.js b/server.js index eb71f17..4c46226 100644 --- a/server.js +++ b/server.js @@ -58,8 +58,6 @@ app.use( express.static(path.join(__dirname, "public")), ); -// Serve other public files -app.use(express.static(path.join(__dirname, "public"))); // Session configuration app.use( session({ diff --git a/views/admin/blog/create.ejs b/views/admin/blog/create.ejs new file mode 100644 index 0000000..0a839d4 --- /dev/null +++ b/views/admin/blog/create.ejs @@ -0,0 +1,1008 @@ +Create a new blog post
+Edit blog post
+Manage blog posts and articles
+| Image | +Title | +Category | +Status | +Author | +Published | +Actions | +
|---|---|---|---|---|---|---|
|
+ <% if (blog.featuredImage) { %>
+
+
+
+ <% } %>
+ |
+
+
+
+ <%= blog.title %>
+
+ <% if (blog.isFeatured) { %>
+ Featured
+ <% } %>
+
+
+ <%= blog.excerpt.substring(0, 60) %>...
+
+ |
+ + <% if (blog.category && blog.category.length> 0) { %> + <% blog.category.slice(0, 2).forEach(cat=> { %> + + <%= cat %> + + <% }); %> + <% if (blog.category.length> 2) { %> + +<%= blog.category.length - 2 %> + <% } %> + <% } else { %> + - + <% } %> + | ++ <% if (blog.status==='published' ) { %> + Published + <% } else { %> + Draft + <% } %> + | ++ <%= blog.author || 'Admin' %> + | ++ <%= blog.publishedAt || '-' %> + | ++ + | +