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

- Home - Contact - About + Home + Contact + About
); 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) => ( + © Jake Runyan 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" + > + + Full size 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;