Skip to content

Commit 8892e35

Browse files
authored
feat(blog): add clickable images that link to full-size versions (#56)
1 parent 5689da7 commit 8892e35

6 files changed

Lines changed: 267 additions & 2 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
dist/
33
# generated types
44
.astro/
5+
# generated original images for lightbox
6+
public/originals/
57

68
# dependencies
79
node_modules/

astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { defineConfig } from 'astro/config';
33
import tailwindcss from '@tailwindcss/vite';
44
import sitemap from '@astrojs/sitemap';
5+
56
// https://astro.build/config
67
export default defineConfig({
78
site: 'https://www.codingwithcalvin.net',

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
"type": "module",
44
"version": "0.0.1",
55
"scripts": {
6-
"dev": "astro dev",
7-
"build": "astro build",
6+
"dev": "node scripts/copy-originals.js && astro dev",
7+
"build": "node scripts/copy-originals.js && astro build",
88
"preview": "astro preview",
99
"astro": "astro",
1010
"new": "node scripts/new-post.js",
1111
"cover": "node scripts/generate-cover.js",
12+
"copy-originals": "node scripts/copy-originals.js",
1213
"compress": "node scripts/compress-images.js"
1314
},
1415
"dependencies": {

scripts/copy-originals.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { readdir, copyFile, mkdir } from "fs/promises";
2+
import { existsSync } from "fs";
3+
import { join, extname } from "path";
4+
import { fileURLToPath } from "url";
5+
import { dirname } from "path";
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url));
8+
const blogDir = join(__dirname, "..", "src", "content", "blog");
9+
const outputDir = join(__dirname, "..", "public", "originals");
10+
11+
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
12+
13+
async function copyOriginals() {
14+
console.log("Copying original images to public/originals...\n");
15+
16+
let copiedCount = 0;
17+
18+
// Get all years
19+
const years = await readdir(blogDir, { withFileTypes: true });
20+
21+
for (const year of years.filter((d) => d.isDirectory())) {
22+
const yearDir = join(blogDir, year.name);
23+
const slugs = await readdir(yearDir, { withFileTypes: true });
24+
25+
for (const slug of slugs.filter((d) => d.isDirectory())) {
26+
const postDir = join(yearDir, slug.name);
27+
const files = await readdir(postDir, { withFileTypes: true });
28+
29+
const images = files.filter(
30+
(f) =>
31+
f.isFile() && IMAGE_EXTENSIONS.includes(extname(f.name).toLowerCase())
32+
);
33+
34+
if (images.length === 0) continue;
35+
36+
// Create output directory for this post
37+
const postOutputDir = join(outputDir, year.name, slug.name);
38+
if (!existsSync(postOutputDir)) {
39+
await mkdir(postOutputDir, { recursive: true });
40+
}
41+
42+
// Copy each image
43+
for (const image of images) {
44+
const src = join(postDir, image.name);
45+
const dest = join(postOutputDir, image.name);
46+
await copyFile(src, dest);
47+
copiedCount++;
48+
}
49+
}
50+
}
51+
52+
console.log(`Copied ${copiedCount} original images.\n`);
53+
}
54+
55+
copyOriginals().catch(console.error);

src/layouts/PostLayout.astro

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,88 @@ const ogImageUrl = image?.src;
8686

8787
pre.replaceWith(div);
8888
});
89+
90+
// Create lightbox overlay
91+
const lightbox = document.createElement('div');
92+
lightbox.className = 'lightbox-overlay';
93+
lightbox.innerHTML = `
94+
<div class="lightbox-header">
95+
<span class="lightbox-title">Full-size image</span>
96+
<span class="lightbox-hint">Click outside or press Esc to close</span>
97+
</div>
98+
<div class="lightbox-container">
99+
<img src="" alt="" />
100+
</div>
101+
<button class="lightbox-close" aria-label="Close">&times;</button>
102+
<div class="lightbox-footer">
103+
<span>Click anywhere outside the image to close</span>
104+
</div>
105+
`;
106+
document.body.appendChild(lightbox);
107+
108+
const lightboxImg = lightbox.querySelector('.lightbox-container img');
109+
const closeBtn = lightbox.querySelector('.lightbox-close');
110+
const container = lightbox.querySelector('.lightbox-container');
111+
112+
// Close lightbox on overlay click (but not container), close button, or Escape key
113+
lightbox.addEventListener('click', (e) => {
114+
if (e.target === lightbox || e.target === closeBtn || e.target.closest('.lightbox-header') || e.target.closest('.lightbox-footer')) {
115+
lightbox.classList.remove('active');
116+
}
117+
});
118+
// Prevent closing when clicking the container
119+
container.addEventListener('click', (e) => e.stopPropagation());
120+
document.addEventListener('keydown', (e) => {
121+
if (e.key === 'Escape') lightbox.classList.remove('active');
122+
});
123+
124+
// Get the current post slug from the URL
125+
const pathParts = window.location.pathname.split('/').filter(Boolean);
126+
const slug = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
127+
128+
// Make prose images clickable to open lightbox with original
129+
document.querySelectorAll('.prose img').forEach((img) => {
130+
// Skip if already wrapped in a link
131+
if (img.parentElement?.tagName === 'A') return;
132+
133+
img.addEventListener('click', () => {
134+
// Extract the filename from the optimized src
135+
const src = img.getAttribute('src') || '';
136+
const alt = img.getAttribute('alt') || '';
137+
138+
// Try to find the original image
139+
// The optimized path is like /_astro/filename.hash.webp
140+
// We need to map it back to /originals/YEAR/SLUG/filename.png
141+
const match = src.match(/\/_astro\/([^.]+)\./);
142+
if (match) {
143+
const baseName = match[1];
144+
// Try common extensions
145+
const year = window.location.pathname.match(/\/(\d{4})\//)?.[1];
146+
if (year && slug) {
147+
// Try to load the original
148+
const originalPath = `/originals/${year}/${slug}/${baseName}.png`;
149+
lightboxImg.src = originalPath;
150+
lightboxImg.alt = alt;
151+
lightbox.classList.add('active');
152+
153+
// Fallback to optimized if original fails
154+
lightboxImg.onerror = () => {
155+
// Try jpg
156+
lightboxImg.src = `/originals/${year}/${slug}/${baseName}.jpg`;
157+
lightboxImg.onerror = () => {
158+
// Fall back to optimized version
159+
lightboxImg.src = src;
160+
};
161+
};
162+
}
163+
} else {
164+
// Use the src as-is if we can't parse it
165+
lightboxImg.src = src;
166+
lightboxImg.alt = alt;
167+
lightbox.classList.add('active');
168+
}
169+
});
170+
});
89171
</script>
90172

91173
<blockquote class="mt-12 border-l-4 border-primary bg-background-2 rounded-r-lg p-6 text-center">

src/styles/global.css

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,130 @@ code {
9595
margin-bottom: 1.5em;
9696
}
9797

98+
/* Clickable images */
99+
.prose img {
100+
cursor: zoom-in;
101+
transition: transform 0.2s ease, box-shadow 0.2s ease;
102+
}
103+
104+
.prose img:hover {
105+
transform: scale(1.01);
106+
box-shadow: 0 4px 20px rgba(51, 145, 203, 0.3);
107+
}
108+
109+
/* Lightbox */
110+
.lightbox-overlay {
111+
position: fixed;
112+
inset: 0;
113+
background: rgba(0, 0, 0, 0.9);
114+
z-index: 9999;
115+
display: flex;
116+
flex-direction: column;
117+
align-items: center;
118+
justify-content: center;
119+
cursor: zoom-out;
120+
opacity: 0;
121+
visibility: hidden;
122+
transition: opacity 0.3s ease, visibility 0.3s ease;
123+
backdrop-filter: blur(8px);
124+
}
125+
126+
.lightbox-overlay.active {
127+
opacity: 1;
128+
visibility: visible;
129+
}
130+
131+
.lightbox-header {
132+
position: absolute;
133+
top: 0;
134+
left: 0;
135+
right: 0;
136+
height: 4rem;
137+
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent);
138+
display: flex;
139+
align-items: center;
140+
justify-content: space-between;
141+
padding: 0 4.5rem 0 1.5rem;
142+
}
143+
144+
.lightbox-title {
145+
color: rgba(255, 255, 255, 0.9);
146+
font-size: 0.875rem;
147+
font-weight: 500;
148+
}
149+
150+
.lightbox-hint {
151+
color: rgba(255, 255, 255, 0.5);
152+
font-size: 0.75rem;
153+
margin-top: 0.5rem;
154+
}
155+
156+
.lightbox-container {
157+
position: relative;
158+
padding: 1rem;
159+
background: var(--color-background-2);
160+
border-radius: 0.75rem;
161+
border: 1px solid var(--color-primary);
162+
box-shadow:
163+
0 0 0 1px rgba(51, 145, 203, 0.3),
164+
0 8px 32px rgba(0, 0, 0, 0.5),
165+
0 0 80px rgba(51, 145, 203, 0.15);
166+
max-width: 95vw;
167+
max-height: 85vh;
168+
display: flex;
169+
align-items: center;
170+
justify-content: center;
171+
}
172+
173+
.lightbox-container img {
174+
max-width: 100%;
175+
max-height: calc(85vh - 2rem);
176+
object-fit: contain;
177+
border-radius: 0.5rem;
178+
}
179+
180+
.lightbox-close {
181+
position: absolute;
182+
top: 1rem;
183+
right: 1rem;
184+
background: var(--color-primary);
185+
border: none;
186+
color: white;
187+
font-size: 1.5rem;
188+
width: 2.5rem;
189+
height: 2.5rem;
190+
border-radius: 50%;
191+
cursor: pointer;
192+
display: flex;
193+
align-items: center;
194+
justify-content: center;
195+
transition: background 0.2s ease, transform 0.2s ease;
196+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
197+
}
198+
199+
.lightbox-close:hover {
200+
background: var(--color-primary-hover);
201+
transform: scale(1.1);
202+
}
203+
204+
.lightbox-footer {
205+
position: absolute;
206+
bottom: 0;
207+
left: 0;
208+
right: 0;
209+
height: 3rem;
210+
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
211+
display: flex;
212+
align-items: center;
213+
justify-content: center;
214+
padding: 0 1.5rem;
215+
}
216+
217+
.lightbox-footer span {
218+
color: rgba(255, 255, 255, 0.6);
219+
font-size: 0.75rem;
220+
}
221+
98222
.prose h2 {
99223
margin-top: 2em;
100224
margin-bottom: 1em;

0 commit comments

Comments
 (0)