diff --git a/content b/content index e63c74e..8db82fa 160000 --- a/content +++ b/content @@ -1 +1 @@ -Subproject commit e63c74e63eeff064d31f08ad3d3f1039f909ac18 +Subproject commit 8db82fa42eaa076f7ad6b9cc5663e62da741d660 diff --git a/public/css/blog_index.css b/public/css/blog_index.css new file mode 100644 index 0000000..d770052 --- /dev/null +++ b/public/css/blog_index.css @@ -0,0 +1,134 @@ +/* Blog Index Styles */ +.post { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + margin-bottom: 2rem; + padding: 2rem; + transition: all 0.3s ease; + border-left: 4px solid transparent; +} + +.post:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + border-left-color: #2c3e50; + transform: translateY(-2px); +} + +.post-header { + margin-bottom: 1.5rem; +} + +.post-title { + margin: 0 0 0.75rem 0; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.3; +} + +.post-link { + color: #2c3e50; + text-decoration: none; + transition: color 0.2s ease; +} + +.post-link:hover { + color: #1a73e8; + text-decoration: underline; +} + +.post-date { + display: inline-block; + font-size: 0.9rem; + color: #6c757d; + font-weight: 500; + margin-bottom: 0.75rem; + padding: 0.25rem 0.75rem; + background-color: #f8f9fa; + border-radius: 4px; + border: 1px solid #e9ecef; +} + +.post-tags { + list-style: none; + padding: 0; + margin: 0.75rem 0 0 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.post-tag { + font-size: 0.8rem; + background-color: #e3f2fd; + color: #1976d2; + padding: 0.25rem 0.6rem; + border-radius: 16px; + font-weight: 500; + transition: all 0.2s ease; + border: 1px solid #bbdefb; +} + +.post-tag:hover { + background-color: #1976d2; + color: #ffffff; + transform: scale(1.05); +} + +.post-excerpt { + margin-bottom: 1.5rem; + color: #495057; + line-height: 1.6; +} + +.post-excerpt p { + margin-bottom: 1rem; +} + +.post-excerpt p:last-child { + margin-bottom: 0; +} + +.post-readmore { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid #e9ecef; +} + +.post-readmore a { + color: #1a73e8; + text-decoration: none; + font-weight: 600; + font-size: 0.95rem; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.post-readmore a:hover { + color: #2c3e50; + text-decoration: underline; + transform: translateX(4px); +} + +/* Responsive adjustments */ +@media (max-width: 600px) { + .post { + padding: 1.5rem; + margin-bottom: 1.5rem; + } + + .post-title { + font-size: 1.3rem; + } + + .post-tags { + margin-top: 0.5rem; + } + + .post-tag { + font-size: 0.75rem; + padding: 0.2rem 0.5rem; + } +} diff --git a/public/css/styles.css b/public/css/styles.css index bbe6d16..5d20fa0 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -100,30 +100,30 @@ } } /* Header */ -header { +header#site-header { background-color: #2c3e50; color: #ecf0f1; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } -header .container { +header#site-header .container { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; padding: 1rem 0; } -header .logo { +header#site-header .logo { font-size: 1.8rem; font-weight: bolder; position: relative; } -header .logo a { +header#site-header .logo a { color: inherit; text-decoration: none; position: relative; display: inline-block; } -header .logo a::after { +header#site-header .logo a::after { content: ""; position: absolute; left: 0; @@ -135,25 +135,9 @@ transform-origin: left; transition: transform 0.3s ease; } -header .logo a:hover::after { +header#site-header .logo a:hover::after { transform: scaleX(1); } -header nav { - display: flex; - gap: 1rem; -} -header nav a { - color: #ecf0f1; - text-decoration: none; - font-weight: 600; - padding: 0.3rem 0.6rem; - border-radius: 3px; - transition: background-color 0.2s ease-in-out; -} -header nav a:hover, -header nav a:focus { - background-color: #34495e; -} /* Layout */ .layout { display: flex; @@ -242,9 +226,56 @@ .menu-posts a:hover { text-decoration: underline; } +header#site-header nav.site-nav { + display: flex; + gap: 1rem; +} +header#site-header nav.site-nav a { + text-decoration: none; + font-weight: 600; + padding: 0.3rem 0.6rem; + border-radius: 3px; + transition: background-color 0.2s ease-in-out; +} +header#site-header nav.site-nav > a { + color: #ecf0f1; +} +header#site-header nav.site-nav a:hover, +header#site-header nav.site-nav a:focus { + background-color: #34495e; +} +nav.site-nav .dropdown { + position: relative; + display: inline-block; +} +nav.site-nav .dropbtn { + display: inline-block; + cursor: pointer; +} +nav.site-nav .dropdown-content { + display: none; + position: absolute; + background-color: white; + min-width: 160px; + z-index: 1; + box-shadow: 0px 8px 16px rgba(0,0,0,0.2); +} +nav.site-nav .dropdown-content a { + display: block; + padding: 12px 16px; + text-decoration: none; + color: #2c3e50;; /* fixme */ +} +nav.site-nav .dropdown-content a:hover { + color: #ecf0f1; + background-color: #f1f1f1; +} +nav.site-nav .dropdown:hover .dropdown-content { + display: block; +} /* Navigation adjustments for small screens */ @media (max-width: 600px) { - nav { + nav.site-nav { width: 100%; height: auto; float: none; @@ -253,7 +284,7 @@ border-bottom: 1px solid #444; background: #111; } - nav ul { + nav.site-nav ul { display: block; justify-content: center; flex-wrap: wrap; @@ -262,16 +293,16 @@ margin: 0; list-style: none; } - nav ul li { + nav.site-nav ul li { margin: 0.25rem 0; border: none; } - nav ul li a { + nav.site-nav ul li a { padding: 0.5rem 1rem; display: block; border-radius: 0; } - nav a { + nav.site-nav a { text-transform: capitalize; } } @@ -281,34 +312,6 @@ padding: 0; box-sizing: border-box; } -.dropdown { - position: relative; - display: inline-block; -} -.dropbtn { - display: inline-block; - cursor: pointer; -} -.dropdown-content { - display: none; - position: absolute; - background-color: white; - min-width: 160px; - z-index: 1; - box-shadow: 0px 8px 16px rgba(0,0,0,0.2); -} -.dropdown-content a { - display: block; - padding: 12px 16px; - text-decoration: none; - color: black; -} -.dropdown-content a:hover { - background-color: #f1f1f1; -} -.dropdown:hover .dropdown-content { - display: block; -} /* Sidebar */ .sidebar { width: 250px; diff --git a/src/css/header.css b/src/css/header.css index 0186ce4..443b0c6 100644 --- a/src/css/header.css +++ b/src/css/header.css @@ -1,11 +1,11 @@ /* Header */ -header { +header#site-header { background-color: #2c3e50; color: #ecf0f1; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } -header .container { +header#site-header .container { display: flex; flex-direction: column; align-items: flex-start; @@ -13,20 +13,20 @@ padding: 1rem 0; } -header .logo { +header#site-header .logo { font-size: 1.8rem; font-weight: bolder; position: relative; } -header .logo a { +header#site-header .logo a { color: inherit; text-decoration: none; position: relative; display: inline-block; } -header .logo a::after { +header#site-header .logo a::after { content: ""; position: absolute; left: 0; @@ -39,26 +39,7 @@ transition: transform 0.3s ease; } -header .logo a:hover::after { +header#site-header .logo a:hover::after { transform: scaleX(1); } -header nav { - display: flex; - gap: 1rem; -} - -header nav a { - color: #ecf0f1; - text-decoration: none; - font-weight: 600; - padding: 0.3rem 0.6rem; - border-radius: 3px; - transition: background-color 0.2s ease-in-out; -} - -header nav a:hover, -header nav a:focus { - background-color: #34495e; -} - diff --git a/src/css/nav.css b/src/css/nav.css index 0120b73..67e8338 100644 --- a/src/css/nav.css +++ b/src/css/nav.css @@ -1,6 +1,63 @@ +header#site-header nav.site-nav { + display: flex; + gap: 1rem; +} + +header#site-header nav.site-nav a { + text-decoration: none; + font-weight: 600; + padding: 0.3rem 0.6rem; + border-radius: 3px; + transition: background-color 0.2s ease-in-out; +} + +header#site-header nav.site-nav > a { + color: #ecf0f1; +} + +header#site-header nav.site-nav a:hover, +header#site-header nav.site-nav a:focus { + background-color: #34495e; +} + +nav.site-nav .dropdown { + position: relative; + display: inline-block; +} + +nav.site-nav .dropbtn { + display: inline-block; + cursor: pointer; +} + +nav.site-nav .dropdown-content { + display: none; + position: absolute; + background-color: white; + min-width: 160px; + z-index: 1; + box-shadow: 0px 8px 16px rgba(0,0,0,0.2); +} + +nav.site-nav .dropdown-content a { + display: block; + padding: 12px 16px; + text-decoration: none; + color: #2c3e50;; /* fixme */ +} + +nav.site-nav .dropdown-content a:hover { + color: #ecf0f1; + background-color: #f1f1f1; +} + +nav.site-nav .dropdown:hover .dropdown-content { + display: block; +} + /* Navigation adjustments for small screens */ @media (max-width: 600px) { - nav { + nav.site-nav { width: 100%; height: auto; float: none; @@ -9,7 +66,7 @@ border-bottom: 1px solid #444; background: #111; } - nav ul { + nav.site-nav ul { display: block; justify-content: center; flex-wrap: wrap; @@ -18,16 +75,16 @@ margin: 0; list-style: none; } - nav ul li { + nav.site-nav ul li { margin: 0.25rem 0; border: none; } - nav ul li a { + nav.site-nav ul li a { padding: 0.5rem 1rem; display: block; border-radius: 0; } - nav a { + nav.site-nav a { text-transform: capitalize; } } diff --git a/src/css/responsive.css b/src/css/responsive.css deleted file mode 100644 index e69de29..0000000 --- a/src/css/responsive.css +++ /dev/null diff --git a/src/css/styles.css b/src/css/styles.css index a350669..18e76f3 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -6,7 +6,5 @@ @import url('menu.css'); @import url('nav.css'); @import url('reset.css'); -@import url('responsive.css'); -@import url('submenu.css'); @import url('sidebar.css'); @import url('toc.css'); diff --git a/src/css/submenu.css b/src/css/submenu.css deleted file mode 100644 index 1e74bed..0000000 --- a/src/css/submenu.css +++ /dev/null @@ -1,33 +0,0 @@ -.dropdown { - position: relative; - display: inline-block; -} - -.dropbtn { - display: inline-block; - cursor: pointer; -} - -.dropdown-content { - display: none; - position: absolute; - background-color: white; - min-width: 160px; - z-index: 1; - box-shadow: 0px 8px 16px rgba(0,0,0,0.2); -} - -.dropdown-content a { - display: block; - padding: 12px 16px; - text-decoration: none; - color: black; -} - -.dropdown-content a:hover { - background-color: #f1f1f1; -} - -.dropdown:hover .dropdown-content { - display: block; -} diff --git a/src/routes/blog_index.js b/src/routes/blog_index.js new file mode 100644 index 0000000..51b50d4 --- /dev/null +++ b/src/routes/blog_index.js @@ -0,0 +1,37 @@ +const express = require("express"); +const path = require("path"); +const { getAllPosts } = require("../utils/postFileUtils"); +const getBaseContext = require("../utils/baseContext"); +const router = express.Router(); + +router.get("/blog", async (req, res, next) => { + const postsDir = path.join(__dirname, "../../content/posts"); + const allPosts = await getAllPosts(postsDir, { + includeUnpublished: req.query.drafts === "true", + }); + + const publishedPosts = allPosts.filter( + (post) => post.published || process.env.NODE_ENV === "production" + ); + // Sort posts descending by date + publishedPosts.sort((a, b) => new Date(b.date) - new Date(a.date)); + + // Prepare context compatible with the blog-index.hbs layout + // Add `templateContent` as excerpt or limited content if needed here + // For now, use a simple excerpt from markdown or placeholder + const posts = publishedPosts.map((post) => ({ + url: `/blog/${post.slug}`, + data: { + title: post.title, + date: post.date, + tags: post.tags, + published: post.published, // add this + }, + templateContent: post.excerpt || "", + })); + + const context = await getBaseContext({ collections: { posts } }); + res.render("pages/blog_index", context); +}); + +module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js index ef04402..fbe6491 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -5,6 +5,7 @@ const getBaseContext = require("../utils/baseContext"); const analytics = require("./analytics"); const robots = require("./robots"); +const blog_index = require("./blog_index"); router.post("/track", analytics); @@ -13,12 +14,13 @@ const post = require("./post"); const pages = require("./pages"); +router.use(blog_index); router.use(robots); router.use(contact); router.use(sitemap); router.use(pages); -router.get("/post/:year/:month/:name", post); +router.get("/blog/:year/:month/:name", post); router.get("/", async (req, res) => { const context = await getBaseContext({ diff --git a/src/services/postsMenuService.js b/src/services/postsMenuService.js index f57418a..49c7d86 100644 --- a/src/services/postsMenuService.js +++ b/src/services/postsMenuService.js @@ -1,51 +1,39 @@ -const matter = require("gray-matter"); - -const path = require("path"); -const fs = require("fs").promises; +// src/services/postsMenuService.js (refactored) +const { getAllPosts } = require("../utils/postFileUtils"); async function getPostsMenu(baseDir) { - const years = (await fs.readdir(baseDir, { withFileTypes: true })).filter( - (dirent) => dirent.isDirectory() && /^\d{4}$/.test(dirent.name) - ); + const allPosts = await getAllPosts(baseDir); + // Group posts by year and month const menu = []; + const yearMap = new Map(); - for (const yearDir of years) { - const yearPath = path.join(baseDir, yearDir.name); - const months = await fs.readdir(yearPath, { withFileTypes: true }); - const monthsData = []; - - for (const monthDir of months.filter((d) => d.isDirectory())) { - const monthPath = path.join(yearPath, monthDir.name); - const files = await fs.readdir(monthPath); - - const posts = await Promise.all( - files - .filter((f) => f.endsWith(".md")) - .map(async (f) => { - const slug = f.replace(/\.md$/, ""); - const filePath = path.join(monthPath, f); - const fileContent = await fs.readFile(filePath, "utf8"); - const { data } = matter(fileContent); - - if (!data.published && process.env.NODE_ENV === "production") { - return null; - } - - return { - slug, - title: data.title || slug.replace(/-/g, " "), - date: data.date || null, - year: yearDir.name, - month: monthDir.name, - }; - }) - ); - - monthsData.push({ month: monthDir.name, posts: posts.filter(Boolean) }); + for (const post of allPosts) { + if (!yearMap.has(post.year)) { + yearMap.set(post.year, new Map()); } - menu.push({ year: yearDir.name, months: monthsData }); + const monthMap = yearMap.get(post.year); + if (!monthMap.has(post.month)) { + monthMap.set(post.month, []); + } + + monthMap.get(post.month).push({ + slug: post.slug, + title: post.title, + date: post.date, + year: post.year, + month: post.month, + }); + } + + // Convert maps to arrays + for (const [year, monthMap] of yearMap) { + const monthsData = []; + for (const [month, posts] of monthMap) { + monthsData.push({ month, posts }); + } + menu.push({ year, months: monthsData }); } return menu; diff --git a/src/services/sitemapService.js b/src/services/sitemapService.js index 7e75e5e..9d47214 100644 --- a/src/services/sitemapService.js +++ b/src/services/sitemapService.js @@ -1,7 +1,7 @@ -// src/services/sitemapService.js +// src/services/sitemapService.js (refactored) const path = require("path"); const fs = require("fs").promises; -const getPostsMenu = require("./postsMenuService"); +const { getAllPosts } = require("../utils/postFileUtils"); class SitemapService { constructor() { @@ -23,25 +23,16 @@ } async getBlogPostUrls() { - const menu = await getPostsMenu(this.postsPath); - const urls = []; + const allPosts = await getAllPosts(this.postsPath); - for (const yearData of menu) { - for (const monthData of yearData.months) { - for (const post of monthData.posts) { - urls.push({ - loc: `/blog/${post.year}/${post.month}/${post.slug}`, - lastmod: post.date - ? new Date(post.date).toISOString().split("T")[0] - : null, - changefreq: "monthly", - priority: "0.7", - }); - } - } - } - - return urls; + return allPosts.map((post) => ({ + loc: `/blog/${post.year}/${post.month}/${post.slug}`, + lastmod: post.date + ? new Date(post.date).toISOString().split("T")[0] + : null, + changefreq: "monthly", + priority: "0.7", + })); } async getCompleteSitemap() { @@ -55,7 +46,7 @@ title: "Blog Posts", children: blogUrls.map((url) => ({ loc: url.loc, - title: url.loc.split("/").pop().replace(/-/g, " "), // Convert slug to title + title: url.loc.split("/").pop().replace(/-/g, " "), lastmod: url.lastmod, changefreq: url.changefreq, priority: url.priority, diff --git a/src/utils/hbsHelpers.js b/src/utils/hbsHelpers.js index 3ae0da8..f591352 100644 --- a/src/utils/hbsHelpers.js +++ b/src/utils/hbsHelpers.js @@ -1,4 +1,3 @@ -// src/utils/hbsHelpers.js function registerHelpers(hbs) { hbs.handlebars.registerHelper("formatMonth", function (monthStr) { const monthNames = [ @@ -19,6 +18,14 @@ if (index < 0 || index > 11) return monthStr; return monthNames[index]; }); + + hbs.handlebars.registerHelper("formatDate", function (date) { + const d = new Date(date); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; + }); } module.exports = { registerHelpers }; diff --git a/src/utils/postFileUtils.js b/src/utils/postFileUtils.js new file mode 100644 index 0000000..3623487 --- /dev/null +++ b/src/utils/postFileUtils.js @@ -0,0 +1,63 @@ +// src/utils/postFileUtils.js +const matter = require("gray-matter"); +const path = require("path"); +const fs = require("fs").promises; + +async function getAllPosts(baseDir, options = {}) { + const { includeUnpublished = false } = options; + + const years = (await fs.readdir(baseDir, { withFileTypes: true })).filter( + (dirent) => dirent.isDirectory() && /^\d{4}$/.test(dirent.name) + ); + + const allPosts = []; + + for (const yearDir of years) { + const yearPath = path.join(baseDir, yearDir.name); + const months = await fs.readdir(yearPath, { withFileTypes: true }); + + for (const monthDir of months.filter((d) => d.isDirectory())) { + const monthPath = path.join(yearPath, monthDir.name); + const files = await fs.readdir(monthPath); + + const posts = await Promise.all( + files + .filter((f) => f.endsWith(".md")) + .map(async (f) => { + const slug = f.replace(/\.md$/, ""); + const filePath = path.join(monthPath, f); + const fileContent = await fs.readFile(filePath, "utf8"); + const { data, content } = matter(fileContent); + + const excerpt = content.replace(/\n+/g, " ").slice(0, 200) + "..."; + + // Filter unpublished posts in production unless explicitly included + if ( + !data.published && + process.env.NODE_ENV === "production" && + !includeUnpublished + ) { + return null; + } + + return { + slug, + title: data.title || slug.replace(/-/g, " "), + date: data.date || null, + year: yearDir.name, + month: monthDir.name, + published: data.published, + frontmatter: data, // Include full frontmatter for flexibility + excerpt, + }; + }) + ); + + allPosts.push(...posts.filter(Boolean)); + } + } + + return allPosts; +} + +module.exports = { getAllPosts }; diff --git a/src/views/pages/blog_index.handlebars b/src/views/pages/blog_index.handlebars new file mode 100644 index 0000000..21d429b --- /dev/null +++ b/src/views/pages/blog_index.handlebars @@ -0,0 +1,39 @@ +{{#section "styles"}} + +{{/section}} +

Blog Posts

+ +{{#each collections.posts}} +{{#if this.data.published}} +
+
+

+ + {{this.data.title}} + +

+ + + {{#if this.data.tags}} + + {{/if}} +
+ + {{#if this.templateContent}} +
+ {{{this.templateContent}}} +
+ {{/if}} + +
+ Read more → +
+
+{{/if}} +{{/each}} diff --git a/src/views/partials/headers.handlebars b/src/views/partials/headers.handlebars index 26666ae..c7deba8 100644 --- a/src/views/partials/headers.handlebars +++ b/src/views/partials/headers.handlebars @@ -1,9 +1,9 @@ -
+