mirror of
https://github.com/runyanjake/jakesphotos.git
synced 2026-03-26 05:23:18 -07:00
Photo preview and polish
This commit is contained in:
parent
da5752ee93
commit
2456040d9d
@ -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
|
||||
|
||||
@ -9,7 +9,7 @@ import Footer from './components/Footer';
|
||||
const App = () => {
|
||||
return (
|
||||
<Router>
|
||||
<div>
|
||||
<>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
@ -17,7 +17,7 @@ const App = () => {
|
||||
<Route path="/about" element={<About />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
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(<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();
|
||||
});
|
||||
|
||||
@ -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 = () => {
|
||||
<p>© {new Date().getFullYear()} Jake Runyan</p>
|
||||
</div>
|
||||
<div className="footer-links right">
|
||||
<a href="/">Home</a>
|
||||
<a href="/contact">Contact</a>
|
||||
<a href="/about">About</a>
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/contact">Contact</Link>
|
||||
<Link to="/about">About</Link>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
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(() => {
|
||||
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 (
|
||||
<div className="gallery" ref={masonryRef}>
|
||||
<Helmet><title>Jake Runyan Photography</title></Helmet>
|
||||
{shuffledImages.map((image, index) => (
|
||||
<img key={index} src={image} alt={`© Jake Runyan ${index + 1}`} className="gallery-photo" />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="gallery" ref={masonryRef}>
|
||||
<Helmet><title>Jake Runyan Photography</title></Helmet>
|
||||
{shuffledImages.map((image) => (
|
||||
<img key={image} src={image} alt="© Jake Runyan" className="gallery-photo" style={{ cursor: 'pointer' }} onClick={() => setFocusedImage(image)} />
|
||||
))}
|
||||
</div>
|
||||
{focusedImage && <Lightbox src={focusedImage} onClose={() => setFocusedImage(null)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
50
src/components/Lightbox.css
Normal file
50
src/components/Lightbox.css
Normal 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;
|
||||
}
|
||||
42
src/components/Lightbox.js
Normal file
42
src/components/Lightbox.js
Normal 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;
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user