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 67e3eef..3bc5ee0 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from 'astro/config'; import tailwindcss from '@tailwindcss/vite'; import sitemap from '@astrojs/sitemap'; + // https://astro.build/config export default defineConfig({ site: 'https://www.codingwithcalvin.net', diff --git a/package.json b/package.json index f1c8a5e..cf29a18 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", + "copy-originals": "node scripts/copy-originals.js", "compress": "node scripts/compress-images.js" }, "dependencies": { 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/styles/global.css b/src/styles/global.css index 8cbbde8..7267f77 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -95,6 +95,130 @@ code { margin-bottom: 1.5em; } +/* Clickable images */ +.prose img { + cursor: zoom-in; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.prose img:hover { + transform: scale(1.01); + box-shadow: 0 4px 20px rgba(51, 145, 203, 0.3); +} + +/* 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 { margin-top: 2em; margin-bottom: 1em;