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
|
# 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
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>© {new Date().getFullYear()} Jake Runyan</p>
|
<p>© {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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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)} />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 { 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;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user