Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
dist/
# generated types
.astro/
# generated original images for lightbox
public/originals/

# dependencies
node_modules/
Expand Down
1 change: 1 addition & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
55 changes: 55 additions & 0 deletions scripts/copy-originals.js
Original file line number Diff line number Diff line change
@@ -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);
82 changes: 82 additions & 0 deletions src/layouts/PostLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div class="lightbox-header">
<span class="lightbox-title">Full-size image</span>
<span class="lightbox-hint">Click outside or press Esc to close</span>
</div>
<div class="lightbox-container">
<img src="" alt="" />
</div>
<button class="lightbox-close" aria-label="Close">&times;</button>
<div class="lightbox-footer">
<span>Click anywhere outside the image to close</span>
</div>
`;
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');
}
});
});
</script>

<blockquote class="mt-12 border-l-4 border-primary bg-background-2 rounded-r-lg p-6 text-center">
Expand Down
124 changes: 124 additions & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down