Photo preview and polish

This commit is contained in:
whitney 2026-03-02 23:26:05 -08:00
parent da5752ee93
commit 2456040d9d
8 changed files with 149 additions and 37 deletions

View File

@ -1,5 +1,5 @@
# Use the official Node.js image as a build stage # 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 # Set the working directory
WORKDIR /app WORKDIR /app

View File

@ -9,7 +9,7 @@ import Footer from './components/Footer';
const App = () => { const App = () => {
return ( return (
<Router> <Router>
<div> <>
<Navbar /> <Navbar />
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
@ -17,7 +17,7 @@ const App = () => {
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
</Routes> </Routes>
<Footer /> <Footer />
</div> </>
</Router> </Router>
); );
}; };

View File

@ -1,8 +1,21 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import App from './App'; import App from './App';
test('renders learn react link', () => { // masonry-layout requires DOM layout APIs not available in jsdom
render(<App />); jest.mock('masonry-layout', () => {
const linkElement = screen.getByText(/learn react/i); return jest.fn().mockImplementation(() => ({
expect(linkElement).toBeInTheDocument(); layout: jest.fn(),
destroy: jest.fn(),
}));
});
test('renders site title in navbar', () => {
render(<App />);
expect(screen.getByText(/Jake Runyan Photography/i)).toBeInTheDocument();
});
test('renders navigation links', () => {
render(<App />);
expect(screen.getByRole('link', { name: /contact/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /about/i })).toBeInTheDocument();
}); });

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import './Footer.css'; import './Footer.css';
const Footer = () => { const Footer = () => {
@ -12,9 +13,9 @@ const Footer = () => {
<p>&copy; {new Date().getFullYear()} Jake Runyan</p> <p>&copy; {new Date().getFullYear()} Jake Runyan</p>
</div> </div>
<div className="footer-links right"> <div className="footer-links right">
<a href="/">Home</a> <Link to="/">Home</Link>
<a href="/contact">Contact</a> <Link to="/contact">Contact</Link>
<a href="/about">About</a> <Link to="/about">About</Link>
</div> </div>
</footer> </footer>
); );

View File

@ -1,23 +1,24 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import Masonry from 'masonry-layout'; 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 { 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) => { const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) { for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); 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; return array;
}; };
const Home = () => { const Home = () => {
const masonryRef = useRef(null); const masonryRef = useRef(null);
const [focusedImage, setFocusedImage] = useState(null);
// Shuffle images on component mount
const shuffledImages = shuffleArray([...images]); // Create a copy and shuffle // Compute once on mount; avoids a re-shuffle on every render
const shuffledImages = useMemo(() => shuffleArray([...images]), []);
useEffect(() => { useEffect(() => {
const masonry = new Masonry(masonryRef.current, { const masonry = new Masonry(masonryRef.current, {
@ -26,31 +27,27 @@ const Home = () => {
percentPosition: true, percentPosition: true,
}); });
// Layout Masonry after images have loaded const relayout = () => masonry.layout();
const imagesLoaded = () => {
masonry.layout();
};
// Add event listener for image load
const imgElements = masonryRef.current.querySelectorAll('img'); const imgElements = masonryRef.current.querySelectorAll('img');
imgElements.forEach(img => { imgElements.forEach(img => img.addEventListener('load', relayout));
img.addEventListener('load', imagesLoaded);
});
return () => { return () => {
imgElements.forEach(img => { imgElements.forEach(img => img.removeEventListener('load', relayout));
img.removeEventListener('load', imagesLoaded); masonry.destroy();
});
}; };
}, []); }, []);
return ( return (
<div className="gallery" ref={masonryRef}> <>
<Helmet><title>Jake Runyan Photography</title></Helmet> <div className="gallery" ref={masonryRef}>
{shuffledImages.map((image, index) => ( <Helmet><title>Jake Runyan Photography</title></Helmet>
<img key={index} src={image} alt={`© Jake Runyan ${index + 1}`} className="gallery-photo" /> {shuffledImages.map((image) => (
))} <img key={image} src={image} alt="© Jake Runyan" className="gallery-photo" style={{ cursor: 'pointer' }} onClick={() => setFocusedImage(image)} />
</div> ))}
</div>
{focusedImage && <Lightbox src={focusedImage} onClose={() => setFocusedImage(null)} />}
</>
); );
}; };

View File

@ -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;
}

View File

@ -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 (
<div
className={`lightbox-overlay${closing ? ' closing' : ''}`}
onClick={() => setClosing(true)}
role="dialog"
aria-modal="true"
>
<button className="lightbox-close" onClick={() => setClosing(true)} aria-label="Close"></button>
<img
className="lightbox-image"
src={src}
alt="Full size"
onClick={() => setClosing(true)}
/>
</div>
);
};
export default Lightbox;

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import './Navbar.css'; import './Navbar.css';
import logo from './static/navbar-logo.png'; 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_dark from './static/instagram-dark.png';
import instagram_light from './static/instagram-light.png'; import instagram_light from './static/instagram-light.png';
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
const Navbar = () => { 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 githubIcon = isDarkMode ? github_light : github_dark;
const instagramIcon = isDarkMode ? instagram_light : instagram_dark; const instagramIcon = isDarkMode ? instagram_light : instagram_dark;