diff --git a/Dockerfile b/Dockerfile
index abfff3e..f67bcc1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Use the official Node.js image as a build stage
-FROM node:14 AS build
+FROM node:20-alpine AS build
# Set the working directory
WORKDIR /app
diff --git a/src/App.js b/src/App.js
index 1b34432..978a4d1 100644
--- a/src/App.js
+++ b/src/App.js
@@ -9,7 +9,7 @@ import Footer from './components/Footer';
const App = () => {
return (
-
+ <>
} />
@@ -17,7 +17,7 @@ const App = () => {
} />
-
+ >
);
};
diff --git a/src/App.test.js b/src/App.test.js
index 1f03afe..04c2c5f 100644
--- a/src/App.test.js
+++ b/src/App.test.js
@@ -1,8 +1,21 @@
import { render, screen } from '@testing-library/react';
import App from './App';
-test('renders learn react link', () => {
- render();
- const linkElement = screen.getByText(/learn react/i);
- expect(linkElement).toBeInTheDocument();
+// masonry-layout requires DOM layout APIs not available in jsdom
+jest.mock('masonry-layout', () => {
+ return jest.fn().mockImplementation(() => ({
+ layout: jest.fn(),
+ destroy: jest.fn(),
+ }));
+});
+
+test('renders site title in navbar', () => {
+ render();
+ expect(screen.getByText(/Jake Runyan Photography/i)).toBeInTheDocument();
+});
+
+test('renders navigation links', () => {
+ render();
+ expect(screen.getByRole('link', { name: /contact/i })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /about/i })).toBeInTheDocument();
});
diff --git a/src/components/Footer.js b/src/components/Footer.js
index e5e534f..9718fb2 100644
--- a/src/components/Footer.js
+++ b/src/components/Footer.js
@@ -1,4 +1,5 @@
import React from 'react';
+import { Link } from 'react-router-dom';
import './Footer.css';
const Footer = () => {
@@ -12,9 +13,9 @@ const Footer = () => {
© {new Date().getFullYear()} Jake Runyan
);
diff --git a/src/components/Home.js b/src/components/Home.js
index bf0ce05..02520ad 100644
--- a/src/components/Home.js
+++ b/src/components/Home.js
@@ -1,23 +1,24 @@
-import React, { useEffect, useRef } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
import Masonry from 'masonry-layout';
-import images from '../data/Images'; // Import the image URLs
+import images from '../data/Images';
import { Helmet } from 'react-helmet';
-import './Home.css'; // Ensure you have your CSS for styling
+import './Home.css';
+import Lightbox from './Lightbox';
-// Shuffle function to randomize the array
const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
- [array[i], array[j]] = [array[j], array[i]]; // Swap elements
+ [array[i], array[j]] = [array[j], array[i]];
}
return array;
};
const Home = () => {
const masonryRef = useRef(null);
-
- // Shuffle images on component mount
- const shuffledImages = shuffleArray([...images]); // Create a copy and shuffle
+ const [focusedImage, setFocusedImage] = useState(null);
+
+ // Compute once on mount; avoids a re-shuffle on every render
+ const shuffledImages = useMemo(() => shuffleArray([...images]), []);
useEffect(() => {
const masonry = new Masonry(masonryRef.current, {
@@ -26,31 +27,27 @@ const Home = () => {
percentPosition: true,
});
- // Layout Masonry after images have loaded
- const imagesLoaded = () => {
- masonry.layout();
- };
+ const relayout = () => masonry.layout();
- // Add event listener for image load
const imgElements = masonryRef.current.querySelectorAll('img');
- imgElements.forEach(img => {
- img.addEventListener('load', imagesLoaded);
- });
+ imgElements.forEach(img => img.addEventListener('load', relayout));
return () => {
- imgElements.forEach(img => {
- img.removeEventListener('load', imagesLoaded);
- });
+ imgElements.forEach(img => img.removeEventListener('load', relayout));
+ masonry.destroy();
};
}, []);
return (
-
-
Jake Runyan Photography
- {shuffledImages.map((image, index) => (
-

- ))}
-
+ <>
+
+
Jake Runyan Photography
+ {shuffledImages.map((image) => (
+

setFocusedImage(image)} />
+ ))}
+
+ {focusedImage && setFocusedImage(null)} />}
+ >
);
};
diff --git a/src/components/Lightbox.css b/src/components/Lightbox.css
new file mode 100644
index 0000000..ab6ba1b
--- /dev/null
+++ b/src/components/Lightbox.css
@@ -0,0 +1,50 @@
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes fadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+
+.lightbox-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.88);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ cursor: zoom-out;
+ animation: fadeIn 0.2s ease;
+}
+
+.lightbox-overlay.closing {
+ animation: fadeOut 0.2s ease forwards;
+}
+
+.lightbox-image {
+ max-width: 90vw;
+ max-height: 90vh;
+ object-fit: contain;
+ border-radius: 4px;
+ cursor: zoom-out;
+}
+
+.lightbox-close {
+ position: absolute;
+ top: 1rem;
+ right: 1.25rem;
+ background: none;
+ border: none;
+ color: #fff;
+ font-size: 1.5rem;
+ cursor: pointer;
+ line-height: 1;
+ opacity: 0.7;
+}
+
+.lightbox-close:hover {
+ opacity: 1;
+}
diff --git a/src/components/Lightbox.js b/src/components/Lightbox.js
new file mode 100644
index 0000000..d48b929
--- /dev/null
+++ b/src/components/Lightbox.js
@@ -0,0 +1,42 @@
+import React, { useEffect, useState } from 'react';
+import './Lightbox.css';
+
+const Lightbox = ({ src, onClose }) => {
+ const [closing, setClosing] = useState(false);
+
+ useEffect(() => {
+ if (!closing) return;
+ const t = setTimeout(onClose, 200);
+ return () => clearTimeout(t);
+ }, [closing, onClose]);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') setClosing(true); };
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, []);
+
+ useEffect(() => {
+ document.body.style.overflow = 'hidden';
+ return () => { document.body.style.overflow = ''; };
+ }, []);
+
+ return (
+ setClosing(true)}
+ role="dialog"
+ aria-modal="true"
+ >
+
+

setClosing(true)}
+ />
+
+ );
+};
+
+export default Lightbox;
diff --git a/src/components/Navbar.js b/src/components/Navbar.js
index 8ab1d8c..4067172 100644
--- a/src/components/Navbar.js
+++ b/src/components/Navbar.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import './Navbar.css';
import logo from './static/navbar-logo.png';
@@ -7,8 +7,17 @@ import github_light from './static/github-light.png';
import instagram_dark from './static/instagram-dark.png';
import instagram_light from './static/instagram-light.png';
+const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
+
const Navbar = () => {
- const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ const [isDarkMode, setIsDarkMode] = useState(darkModeQuery.matches);
+
+ useEffect(() => {
+ const handler = (e) => setIsDarkMode(e.matches);
+ darkModeQuery.addEventListener('change', handler);
+ return () => darkModeQuery.removeEventListener('change', handler);
+ }, []);
+
const githubIcon = isDarkMode ? github_light : github_dark;
const instagramIcon = isDarkMode ? instagram_light : instagram_dark;