Skip to content

Commit 49c5701

Browse files
feat(frontend): add rotating camera-roll backgrounds for home and about
Agent-Logs-Url: https://github.com/conorheffron/ironoc/sessions/1df5c7f9-1706-4315-bf90-c186f1872a8d Co-authored-by: conorheffron <8218626+conorheffron@users.noreply.github.com>
1 parent 4536541 commit 49c5701

9 files changed

Lines changed: 295 additions & 10 deletions

File tree

frontend/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,23 @@ frontend
7272
└── setupTests.js
7373
```
7474

75+
## Camera Roll Background Config
76+
77+
Home and About background image rotation is driven by `public/camera-roll.yml`.
78+
79+
Example:
80+
81+
```yml
82+
home:
83+
- teal-bg
84+
- navy-bg
85+
about:
86+
- navy-bg
87+
- red-bg
88+
```
89+
90+
Supported keys are `teal-bg`, `navy-bg`, and `red-bg`.
91+
7592
#### Quick start
7693
```shell
7794
cd frontend

frontend/public/camera-roll.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Camera roll keys for rotating Home/About backgrounds
2+
home:
3+
- teal-bg
4+
- navy-bg
5+
- red-bg
6+
about:
7+
- navy-bg
8+
- red-bg
9+
- teal-bg

frontend/src/App.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@
2626
border-bottom-left-radius: 18%;
2727
}
2828

29+
.App-header-camera-roll {
30+
background-position: center;
31+
background-size: cover;
32+
background-repeat: no-repeat;
33+
transition: background-image 1.1s ease-in-out;
34+
animation: subtle-camera-roll-pan 24s ease-in-out infinite alternate;
35+
box-shadow: inset 0 0 0 1000px rgba(12, 31, 66, 0.15);
36+
}
37+
38+
.App-header-camera-roll--home {
39+
background-color: #1b3f83;
40+
}
41+
42+
.App-header-camera-roll--about {
43+
background-color: #16356c;
44+
}
45+
2946
.App-link {
3047
color: #61dafb;
3148
}
@@ -34,6 +51,17 @@
3451
from {
3552
transform: rotate(0deg);
3653
}
54+
55+
@keyframes subtle-camera-roll-pan {
56+
from {
57+
background-position: 48% 50%;
58+
background-size: 104%;
59+
}
60+
to {
61+
background-position: 52% 46%;
62+
background-size: 110%;
63+
}
64+
}
3765
to {
3866
transform: rotate(360deg);
3967
}

frontend/src/components/About.js

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,48 @@ import React, { Component } from 'react';
22
import { Container } from 'reactstrap';
33
import '.././App.css';
44
import AppNavbar from '.././AppNavbar';
5+
import { loadCameraRollImages } from '../utils/cameraRollConfig';
56

67
class About extends Component {
8+
constructor(props) {
9+
super(props);
10+
this.state = {
11+
backgroundImages: [],
12+
backgroundImageIndex: 0
13+
};
14+
15+
this.rotateCameraRollImage = this.rotateCameraRollImage.bind(this);
16+
this.rotationIntervalId = null;
17+
this.isMountedView = false;
18+
}
19+
20+
async componentDidMount() {
21+
this.isMountedView = true;
22+
const backgroundImages = await loadCameraRollImages('about');
23+
24+
if (this.isMountedView) {
25+
this.setState({ backgroundImages, backgroundImageIndex: 0 });
26+
27+
if (backgroundImages.length > 1) {
28+
this.rotationIntervalId = setInterval(this.rotateCameraRollImage, 10000);
29+
}
30+
}
31+
}
32+
33+
componentWillUnmount() {
34+
this.isMountedView = false;
35+
36+
if (this.rotationIntervalId) {
37+
clearInterval(this.rotationIntervalId);
38+
}
39+
}
40+
41+
rotateCameraRollImage() {
42+
this.setState((prevState) => ({
43+
backgroundImageIndex: (prevState.backgroundImageIndex + 1) % prevState.backgroundImages.length
44+
}));
45+
}
46+
747
render() {
848
const {
949
link = "https://www.linkedin.com/in/conorheffron",
@@ -15,12 +55,21 @@ class About extends Component {
1555
stravaImgSrc = 'https://badges.strava.com/logo-strava.png',
1656
stravaImgAlt = 'Strava'
1757
} = this.props;
58+
const { backgroundImages, backgroundImageIndex } = this.state;
59+
const activeBackgroundImage = backgroundImages[backgroundImageIndex];
60+
const headerStyle = activeBackgroundImage
61+
? { backgroundImage: `linear-gradient(rgba(8, 36, 74, 0.6), rgba(8, 36, 74, 0.6)), url(${activeBackgroundImage})` }
62+
: undefined;
1863

1964
return (
2065
<div className="App">
2166
<AppNavbar />
2267
<Container>
23-
<header className="App-header">
68+
<header
69+
data-testid="about-header"
70+
className="App-header App-header-camera-roll App-header-camera-roll--about"
71+
style={headerStyle}
72+
>
2473
<br /><br />
2574
<a href={link} target="_blank" rel="noreferrer">
2675
<img src={imgSrc} width={imgWidth} height={imgHeight} border="0" alt={imgAlt}></img>

frontend/src/components/Home.js

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { Component } from 'react';
22
import { Container } from 'reactstrap';
33
import '.././App.css';
44
import AppNavbar from '.././AppNavbar';
5+
import { loadCameraRollImages } from '../utils/cameraRollConfig';
56

67
class Home extends Component {
78
constructor(props) {
@@ -17,18 +18,59 @@ class Home extends Component {
1718
className,
1819
headerClassName,
1920
introId,
20-
welcomeMessage
21+
welcomeMessage,
22+
backgroundImages: [],
23+
backgroundImageIndex: 0
2124
};
25+
26+
this.rotateCameraRollImage = this.rotateCameraRollImage.bind(this);
27+
this.rotationIntervalId = null;
28+
this.isMountedView = false;
29+
}
30+
31+
async componentDidMount() {
32+
this.isMountedView = true;
33+
const backgroundImages = await loadCameraRollImages('home');
34+
35+
if (this.isMountedView) {
36+
this.setState({ backgroundImages, backgroundImageIndex: 0 });
37+
38+
if (backgroundImages.length > 1) {
39+
this.rotationIntervalId = setInterval(this.rotateCameraRollImage, 10000);
40+
}
41+
}
42+
}
43+
44+
componentWillUnmount() {
45+
this.isMountedView = false;
46+
47+
if (this.rotationIntervalId) {
48+
clearInterval(this.rotationIntervalId);
49+
}
50+
}
51+
52+
rotateCameraRollImage() {
53+
this.setState((prevState) => ({
54+
backgroundImageIndex: (prevState.backgroundImageIndex + 1) % prevState.backgroundImages.length
55+
}));
2256
}
2357

2458
render() {
25-
const { className, headerClassName, introId, welcomeMessage } = this.state;
59+
const { className, headerClassName, introId, welcomeMessage, backgroundImages, backgroundImageIndex } = this.state;
60+
const activeBackgroundImage = backgroundImages[backgroundImageIndex];
61+
const headerStyle = activeBackgroundImage
62+
? { backgroundImage: `linear-gradient(rgba(10, 34, 80, 0.55), rgba(10, 34, 80, 0.55)), url(${activeBackgroundImage})` }
63+
: undefined;
2664

2765
return (
2866
<div className={className}>
2967
<AppNavbar />
3068
<Container>
31-
<header className={headerClassName}>
69+
<header
70+
data-testid="home-header"
71+
className={`${headerClassName} App-header-camera-roll App-header-camera-roll--home`}
72+
style={headerStyle}
73+
>
3274
<br /><br />
3375
<p id={introId}>
3476
{welcomeMessage}<br /><br />
Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
1-
import { screen, render } from '@testing-library/react';
1+
import { screen, render, waitFor, act } from '@testing-library/react';
22
import About from '../About';
33

44
describe('About Component', () => {
5-
test('renders with default props', () => {
6-
render(<About />);
5+
beforeEach(() => {
6+
jest.spyOn(global, 'fetch').mockResolvedValue({
7+
ok: true,
8+
text: async () => `about:\n - navy-bg\n - red-bg`,
9+
});
10+
});
11+
12+
afterEach(() => {
13+
jest.restoreAllMocks();
14+
});
15+
16+
test('renders with default props', async () => {
17+
await act(async () => {
18+
render(<About />);
19+
});
720

821
expect(screen.getByAltText("View Conor Heffron's profile on LinkedIn")).toBeInTheDocument();
922
expect(screen.getByAltText('Strava')).toBeInTheDocument();
1023
});
1124

12-
test('renders with provided props', () => {
25+
test('renders with provided props', async () => {
1326
const props = {
1427
link: 'https://example.com',
1528
imgSrc: 'https://example.com/image.png',
@@ -18,8 +31,20 @@ describe('About Component', () => {
1831
stravaImgSrc: 'https://example.com/strava-image.png',
1932
stravaImgAlt: 'Example Strava',
2033
};
21-
render(<About {...props} />);
34+
await act(async () => {
35+
render(<About {...props} />);
36+
});
2237
expect(screen.getByAltText('Example Image')).toBeInTheDocument();
2338
expect(screen.getByAltText('Example Strava')).toBeInTheDocument();
2439
});
40+
41+
test('requests camera roll config on mount', async () => {
42+
await act(async () => {
43+
render(<About />);
44+
});
45+
46+
await waitFor(() => {
47+
expect(global.fetch).toHaveBeenCalledWith('/camera-roll.yml', { cache: 'no-store' });
48+
});
49+
});
2550
});
Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
1-
import { render, screen, act } from '@testing-library/react';
1+
import { render, screen, act, waitFor } from '@testing-library/react';
22
import Home from '../Home';
33

44
describe('Home', () => {
5+
beforeEach(() => {
6+
jest.spyOn(global, 'fetch').mockResolvedValue({
7+
ok: true,
8+
text: async () => `home:\n - red-bg\n - teal-bg`,
9+
});
10+
});
11+
12+
afterEach(() => {
13+
jest.restoreAllMocks();
14+
});
15+
516
test('renders Home component', async () => {
617
await act(async () => {
718
render(<Home />);
819
});
920
const element = screen.getByText(/Home/i);
1021
expect(element).toBeInTheDocument();
1122
});
23+
24+
test('requests camera roll config on mount', async () => {
25+
render(<Home />);
26+
27+
await waitFor(() => {
28+
expect(global.fetch).toHaveBeenCalledWith('/camera-roll.yml', { cache: 'no-store' });
29+
});
30+
});
1231
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { loadCameraRollImages } from '../cameraRollConfig';
2+
3+
describe('cameraRollConfig', () => {
4+
afterEach(() => {
5+
jest.restoreAllMocks();
6+
});
7+
8+
test('loads configured images for a section from camera-roll.yml', async () => {
9+
jest.spyOn(global, 'fetch').mockResolvedValue({
10+
ok: true,
11+
text: async () => `home:\n - red-bg\n - teal-bg`,
12+
});
13+
14+
const images = await loadCameraRollImages('home');
15+
16+
expect(images).toHaveLength(2);
17+
expect(images[0]).toContain('red-bg');
18+
expect(images[1]).toContain('teal-bg');
19+
});
20+
21+
test('falls back to defaults when config fetch fails', async () => {
22+
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('network error'));
23+
24+
const images = await loadCameraRollImages('about');
25+
26+
expect(images).toHaveLength(3);
27+
expect(images[0]).toContain('darkblue-bg');
28+
});
29+
});

0 commit comments

Comments
 (0)