From 0ae77d0d647332010fb46c0cdf653ab826582d30 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 29 Jan 2026 09:25:25 -0500 Subject: [PATCH 1/2] feat(blog): add clickable images that link to full-size versions Adds a rehype plugin that wraps markdown images in anchor tags pointing to the original image. Images open in a new tab and have a subtle zoom-in cursor and hover effect to indicate clickability. --- astro.config.mjs | 5 ++++- src/plugins/rehype-image-links.js | 37 +++++++++++++++++++++++++++++++ src/styles/global.css | 19 ++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/plugins/rehype-image-links.js diff --git a/astro.config.mjs b/astro.config.mjs index 67e3eef..40363cd 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,8 @@ import { defineConfig } from 'astro/config'; import tailwindcss from '@tailwindcss/vite'; import sitemap from '@astrojs/sitemap'; +import rehypeImageLinks from './src/plugins/rehype-image-links.js'; + // https://astro.build/config export default defineConfig({ site: 'https://www.codingwithcalvin.net', @@ -14,6 +16,7 @@ export default defineConfig({ shikiConfig: { theme: 'github-dark', wrap: true - } + }, + rehypePlugins: [rehypeImageLinks] } }); diff --git a/src/plugins/rehype-image-links.js b/src/plugins/rehype-image-links.js new file mode 100644 index 0000000..a0d3e79 --- /dev/null +++ b/src/plugins/rehype-image-links.js @@ -0,0 +1,37 @@ +import { visit } from "unist-util-visit"; + +/** + * Rehype plugin to wrap images in links to their full-size versions. + * Skips images that are already wrapped in links. + */ +export default function rehypeImageLinks() { + return (tree) => { + visit(tree, "element", (node, index, parent) => { + // Only process img elements + if (node.tagName !== "img") return; + + // Skip if already wrapped in a link + if (parent?.tagName === "a") return; + + // Skip if no src + const src = node.properties?.src; + if (!src) return; + + // Create the wrapper anchor element + const wrapper = { + type: "element", + tagName: "a", + properties: { + href: src, + target: "_blank", + rel: "noopener noreferrer", + class: "image-link", + }, + children: [node], + }; + + // Replace the img with the wrapped version + parent.children[index] = wrapper; + }); + }; +} diff --git a/src/styles/global.css b/src/styles/global.css index 8cbbde8..0c1d0ba 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -95,6 +95,25 @@ code { margin-bottom: 1.5em; } +/* Clickable images */ +.prose a.image-link { + display: block; + text-decoration: none; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.prose a.image-link:hover { + text-decoration: none; + transform: scale(1.01); + box-shadow: 0 4px 20px rgba(51, 145, 203, 0.3); +} + +.prose a.image-link img { + margin-top: 0; + margin-bottom: 0; + cursor: zoom-in; +} + .prose h2 { margin-top: 2em; margin-bottom: 1em; From e31e42e286ee8def6a4a6e7a29a8e7fe16e0e5f0 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 29 Jan 2026 09:37:54 -0500 Subject: [PATCH 2/2] feat(blog): add lightbox for full-size original images - Click any image in a blog post to open it in a lightbox - Serves original unoptimized images for full resolution viewing - Adds copy-originals script to copy source images to public folder - Lightbox includes header, bordered container, and close instructions - Close via X button, clicking outside, or pressing Escape - Subtle hover effects on images indicate clickability --- .gitignore | 2 + astro.config.mjs | 4 +- package.json | 7 +- scripts/copy-originals.js | 55 +++++++++++++ src/layouts/PostLayout.astro | 82 ++++++++++++++++++++ src/plugins/rehype-image-links.js | 37 --------- src/styles/global.css | 123 +++++++++++++++++++++++++++--- 7 files changed, 258 insertions(+), 52 deletions(-) create mode 100644 scripts/copy-originals.js delete mode 100644 src/plugins/rehype-image-links.js diff --git a/.gitignore b/.gitignore index 5dc86f1..d62c839 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ dist/ # generated types .astro/ +# generated original images for lightbox +public/originals/ # dependencies node_modules/ diff --git a/astro.config.mjs b/astro.config.mjs index 40363cd..3bc5ee0 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,7 +2,6 @@ import { defineConfig } from 'astro/config'; import tailwindcss from '@tailwindcss/vite'; import sitemap from '@astrojs/sitemap'; -import rehypeImageLinks from './src/plugins/rehype-image-links.js'; // https://astro.build/config export default defineConfig({ @@ -16,7 +15,6 @@ export default defineConfig({ shikiConfig: { theme: 'github-dark', wrap: true - }, - rehypePlugins: [rehypeImageLinks] + } } }); diff --git a/package.json b/package.json index 8815011..76b7e5e 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,13 @@ "type": "module", "version": "0.0.1", "scripts": { - "dev": "astro dev", - "build": "astro build", + "dev": "node scripts/copy-originals.js && astro dev", + "build": "node scripts/copy-originals.js && astro build", "preview": "astro preview", "astro": "astro", "new": "node scripts/new-post.js", - "cover": "node scripts/generate-cover.js" + "cover": "node scripts/generate-cover.js", + "copy-originals": "node scripts/copy-originals.js" }, "dependencies": { "@astrojs/rss": "^4.0.14", diff --git a/scripts/copy-originals.js b/scripts/copy-originals.js new file mode 100644 index 0000000..eb41a0c --- /dev/null +++ b/scripts/copy-originals.js @@ -0,0 +1,55 @@ +import { readdir, copyFile, mkdir } from "fs/promises"; +import { existsSync } from "fs"; +import { join, extname } from "path"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const blogDir = join(__dirname, "..", "src", "content", "blog"); +const outputDir = join(__dirname, "..", "public", "originals"); + +const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"]; + +async function copyOriginals() { + console.log("Copying original images to public/originals...\n"); + + let copiedCount = 0; + + // Get all years + const years = await readdir(blogDir, { withFileTypes: true }); + + for (const year of years.filter((d) => d.isDirectory())) { + const yearDir = join(blogDir, year.name); + const slugs = await readdir(yearDir, { withFileTypes: true }); + + for (const slug of slugs.filter((d) => d.isDirectory())) { + const postDir = join(yearDir, slug.name); + const files = await readdir(postDir, { withFileTypes: true }); + + const images = files.filter( + (f) => + f.isFile() && IMAGE_EXTENSIONS.includes(extname(f.name).toLowerCase()) + ); + + if (images.length === 0) continue; + + // Create output directory for this post + const postOutputDir = join(outputDir, year.name, slug.name); + if (!existsSync(postOutputDir)) { + await mkdir(postOutputDir, { recursive: true }); + } + + // Copy each image + for (const image of images) { + const src = join(postDir, image.name); + const dest = join(postOutputDir, image.name); + await copyFile(src, dest); + copiedCount++; + } + } + } + + console.log(`Copied ${copiedCount} original images.\n`); +} + +copyOriginals().catch(console.error); diff --git a/src/layouts/PostLayout.astro b/src/layouts/PostLayout.astro index d54f2cd..5ceb1ba 100644 --- a/src/layouts/PostLayout.astro +++ b/src/layouts/PostLayout.astro @@ -86,6 +86,88 @@ const ogImageUrl = image?.src; pre.replaceWith(div); }); + + // Create lightbox overlay + const lightbox = document.createElement('div'); + lightbox.className = 'lightbox-overlay'; + lightbox.innerHTML = ` + + + + + `; + document.body.appendChild(lightbox); + + const lightboxImg = lightbox.querySelector('.lightbox-container img'); + const closeBtn = lightbox.querySelector('.lightbox-close'); + const container = lightbox.querySelector('.lightbox-container'); + + // Close lightbox on overlay click (but not container), close button, or Escape key + lightbox.addEventListener('click', (e) => { + if (e.target === lightbox || e.target === closeBtn || e.target.closest('.lightbox-header') || e.target.closest('.lightbox-footer')) { + lightbox.classList.remove('active'); + } + }); + // Prevent closing when clicking the container + container.addEventListener('click', (e) => e.stopPropagation()); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') lightbox.classList.remove('active'); + }); + + // Get the current post slug from the URL + const pathParts = window.location.pathname.split('/').filter(Boolean); + const slug = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2]; + + // Make prose images clickable to open lightbox with original + document.querySelectorAll('.prose img').forEach((img) => { + // Skip if already wrapped in a link + if (img.parentElement?.tagName === 'A') return; + + img.addEventListener('click', () => { + // Extract the filename from the optimized src + const src = img.getAttribute('src') || ''; + const alt = img.getAttribute('alt') || ''; + + // Try to find the original image + // The optimized path is like /_astro/filename.hash.webp + // We need to map it back to /originals/YEAR/SLUG/filename.png + const match = src.match(/\/_astro\/([^.]+)\./); + if (match) { + const baseName = match[1]; + // Try common extensions + const year = window.location.pathname.match(/\/(\d{4})\//)?.[1]; + if (year && slug) { + // Try to load the original + const originalPath = `/originals/${year}/${slug}/${baseName}.png`; + lightboxImg.src = originalPath; + lightboxImg.alt = alt; + lightbox.classList.add('active'); + + // Fallback to optimized if original fails + lightboxImg.onerror = () => { + // Try jpg + lightboxImg.src = `/originals/${year}/${slug}/${baseName}.jpg`; + lightboxImg.onerror = () => { + // Fall back to optimized version + lightboxImg.src = src; + }; + }; + } + } else { + // Use the src as-is if we can't parse it + lightboxImg.src = src; + lightboxImg.alt = alt; + lightbox.classList.add('active'); + } + }); + });
diff --git a/src/plugins/rehype-image-links.js b/src/plugins/rehype-image-links.js deleted file mode 100644 index a0d3e79..0000000 --- a/src/plugins/rehype-image-links.js +++ /dev/null @@ -1,37 +0,0 @@ -import { visit } from "unist-util-visit"; - -/** - * Rehype plugin to wrap images in links to their full-size versions. - * Skips images that are already wrapped in links. - */ -export default function rehypeImageLinks() { - return (tree) => { - visit(tree, "element", (node, index, parent) => { - // Only process img elements - if (node.tagName !== "img") return; - - // Skip if already wrapped in a link - if (parent?.tagName === "a") return; - - // Skip if no src - const src = node.properties?.src; - if (!src) return; - - // Create the wrapper anchor element - const wrapper = { - type: "element", - tagName: "a", - properties: { - href: src, - target: "_blank", - rel: "noopener noreferrer", - class: "image-link", - }, - children: [node], - }; - - // Replace the img with the wrapped version - parent.children[index] = wrapper; - }); - }; -} diff --git a/src/styles/global.css b/src/styles/global.css index 0c1d0ba..7267f77 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -96,22 +96,127 @@ code { } /* Clickable images */ -.prose a.image-link { - display: block; - text-decoration: none; +.prose img { + cursor: zoom-in; transition: transform 0.2s ease, box-shadow 0.2s ease; } -.prose a.image-link:hover { - text-decoration: none; +.prose img:hover { transform: scale(1.01); box-shadow: 0 4px 20px rgba(51, 145, 203, 0.3); } -.prose a.image-link img { - margin-top: 0; - margin-bottom: 0; - cursor: zoom-in; +/* Lightbox */ +.lightbox-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.9); + z-index: 9999; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: zoom-out; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; + backdrop-filter: blur(8px); +} + +.lightbox-overlay.active { + opacity: 1; + visibility: visible; +} + +.lightbox-header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4rem; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 4.5rem 0 1.5rem; +} + +.lightbox-title { + color: rgba(255, 255, 255, 0.9); + font-size: 0.875rem; + font-weight: 500; +} + +.lightbox-hint { + color: rgba(255, 255, 255, 0.5); + font-size: 0.75rem; + margin-top: 0.5rem; +} + +.lightbox-container { + position: relative; + padding: 1rem; + background: var(--color-background-2); + border-radius: 0.75rem; + border: 1px solid var(--color-primary); + box-shadow: + 0 0 0 1px rgba(51, 145, 203, 0.3), + 0 8px 32px rgba(0, 0, 0, 0.5), + 0 0 80px rgba(51, 145, 203, 0.15); + max-width: 95vw; + max-height: 85vh; + display: flex; + align-items: center; + justify-content: center; +} + +.lightbox-container img { + max-width: 100%; + max-height: calc(85vh - 2rem); + object-fit: contain; + border-radius: 0.5rem; +} + +.lightbox-close { + position: absolute; + top: 1rem; + right: 1rem; + background: var(--color-primary); + border: none; + color: white; + font-size: 1.5rem; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease, transform 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.lightbox-close:hover { + background: var(--color-primary-hover); + transform: scale(1.1); +} + +.lightbox-footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3rem; + background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent); + display: flex; + align-items: center; + justify-content: center; + padding: 0 1.5rem; +} + +.lightbox-footer span { + color: rgba(255, 255, 255, 0.6); + font-size: 0.75rem; } .prose h2 {