Skip to content

Commit

Permalink
Banner image placeholder and fallback
Browse files Browse the repository at this point in the history
  • Loading branch information
DukeManh committed Mar 17, 2022
1 parent 80cee90 commit 7fbecfc
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 45 deletions.
10 changes: 10 additions & 0 deletions src/api/image/src/lib/photos.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ function getRandomPhotoFilename() {
return path.join(photosDir, photoFilename);
}

// If users want to get a specific photo but don't know the file name, they have an option
// to specify the index of the photo to get a consistent photo vs a random one everytime
// Treat photos as a circular array, any index value >= 0 is valid
function getPhotoAt(index) {
const atIndex = index < 0 ? 0 : index % photos.length;
const photoFilename = photos[atIndex];
return path.join(photosDir, photoFilename);
}

// Get a specific image filename from the photos/ directory.
function getPhotoFilename(image) {
return path.join(photosDir, image);
Expand All @@ -45,5 +54,6 @@ function getPhotoFilename(image) {
exports.download = download;
exports.getRandomPhotoFilename = getRandomPhotoFilename;
exports.getPhotoFilename = getPhotoFilename;
exports.getPhotoAt = getPhotoAt;
exports.photosDir = photosDir;
exports.getPhotos = () => [...photos];
32 changes: 22 additions & 10 deletions src/api/image/src/routes/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,28 @@ const { Router, createError } = require('@senecacdot/satellite');
const { celebrate, Joi, errors, Segments } = require('celebrate');

const { optimize } = require('../lib/image');
const { getRandomPhotoFilename, getPhotoFilename } = require('../lib/photos');
const { getRandomPhotoFilename, getPhotoFilename, getPhotoAt } = require('../lib/photos');

const router = Router();

/**
* Support the following optional query params:
*
* - w: the width to resize the image to. Must be 200-2000. Defaults to 800.
* - h: the height to resize the image to. Must be 200-3000. Defaults to height of image at width=800
* - w: the width to resize the image to. Must be 40-2000. Defaults to 800.
* - h: the height to resize the image to. Must be 40-3000. Defaults to height of image at width=800
* - t: the image type to render, one of: jpeg, jpg, png, webp. Defaults to jpeg.
*
* We also support passing an image name as a param in the URL:
* We also support passing an image name or image image index as a param in the URL:
*
* - image: should look like look '_ok8uVzL2gI.jpg'. Don't allow filenames like '../../dangerous/path/traversal'.
* - index: Can be any positive integer, will return a photo at a index in the circular buffer
* - image: should look like '_ok8uVzL2gI.jpg'. Don't allow filenames like '../../dangerous/path/traversal'.
*/
router.use(
celebrate({
[Segments.QUERY]: Joi.object().keys({
t: Joi.string().valid('jpeg', 'jpg', 'webp', 'png'),
w: Joi.number().integer().min(200).max(2000),
h: Joi.number().integer().min(200).max(3000),
w: Joi.number().integer().min(40).max(2000),
h: Joi.number().integer().min(40).max(3000),
}),
})
);
Expand Down Expand Up @@ -64,9 +65,11 @@ const optimizeImage = (stream, req, res) => {
router.use(
'/:image?',
/**
* Either the client requests an image by name, or we pick one at random.
* In both cases, we supply one on `req.imageFilename`. If the requested
* image doesn't exist, we'll 404 here.
* Either the client requests an image by name or index, or we pick one at random.
* As users don't know about image file name, :image can be
* a random number that refers to a consistent photo
* In both cases, we supply one on `req.imageFilename`.
* If the requested image doesn't exist, we'll 404 here.
*/
function pickImage(req, res, next) {
const { image } = req.params;
Expand All @@ -78,6 +81,15 @@ router.use(
return;
}

// Return a specific photo at an index
// using isNaN() to check if a string is a not number, Number.isNaN() only checks if the value is NaN
// eslint-disable-next-line no-restricted-globals
if (!isNaN(image)) {
req.imageFilename = getPhotoAt(image | 0);
next();
return;
}

// Don't allow path manipulation, only simple filenames matching our images
if (!/^[-_a-zA-Z0-9]+\.jpg$/.test(image)) {
next(createError(400, `Invalid image name: '${image}'`));
Expand Down
16 changes: 12 additions & 4 deletions src/api/image/test/image.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ describe('/image', () => {
request(app).get('/default.jpg').expect(200, done);
});

it('should return 200 when requesting an image at an index', (done) => {
request(app).get('/138').expect(200, done);
});

it('should return the default image when requesting an image at a negative index', (done) => {
request(app).get('/-23').expect(200, done);
});

it('should return 200 when requesting no image', (done) => {
request(app).get('/').expect(200, done);
});
Expand Down Expand Up @@ -67,8 +75,8 @@ describe('/image', () => {
request(app).get('/?w=one').expect(400, done);
});

it('should return 400 if width is under 200', (done) => {
request(app).get('/?w=199').expect(400, done);
it('should return 400 if width is under 40', (done) => {
request(app).get('/?w=39').expect(400, done);
});

it('should return 400 if width is over 2000', (done) => {
Expand All @@ -79,8 +87,8 @@ describe('/image', () => {
request(app).get('/?h=one').expect(400, done);
});

it('should return 400 if height is under 200', (done) => {
request(app).get('/?h=199').expect(400, done);
it('should return 400 if height is under 40', (done) => {
request(app).get('/?h=39').expect(400, done);
});

it('should return 400 if height is over 3000', (done) => {
Expand Down
5 changes: 3 additions & 2 deletions src/web/src/components/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,10 @@ const useStyles = makeStyles((theme: Theme) =>

type BannerProps = {
onVisibilityChange: (visible: boolean) => void;
bannerVisible: boolean;
};

export default function Banner({ onVisibilityChange }: BannerProps) {
export default function Banner({ onVisibilityChange, bannerVisible }: BannerProps) {
const classes = useStyles();
const [gitInfo, setGitInfo] = useState({
gitHubUrl: '',
Expand Down Expand Up @@ -180,7 +181,7 @@ export default function Banner({ onVisibilityChange }: BannerProps) {
return (
<>
<div className={classes.heroBanner} ref={bannerAnchor}>
<BannerDynamicItems />
<BannerDynamicItems visible={bannerVisible} />
<BannerButtons />
</div>
<div className={classes.textsContainer}>
Expand Down
6 changes: 4 additions & 2 deletions src/web/src/components/BannerButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import { makeStyles, Tooltip, withStyles, Zoom } from '@material-ui/core';
import Button from '@material-ui/core/Button';
import clsx from 'clsx';
import useAuth from '../hooks/use-auth';
import TelescopeAvatar from './TelescopeAvatar';
import PopUp from './PopUp';
Expand Down Expand Up @@ -55,9 +56,10 @@ const BannerButtons = () => {

return (
<div
className={`${classes.buttonsContainer} ${
className={clsx(
classes.buttonsContainer,
user?.isRegistered ? classes.userSignedInClass : classes.userNotSignedClass
}`}
)}
>
{user && !user?.isRegistered && (
<PopUp
Expand Down
37 changes: 26 additions & 11 deletions src/web/src/components/BannerDynamicItems.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,44 @@
import { makeStyles, Theme } from '@material-ui/core/styles';
import { makeStyles } from '@material-ui/core/styles';
import NoSsr from '@material-ui/core/NoSsr';

import DynamicImage from './DynamicImage';
import { imageServiceUrl } from '../config';

const useStyles = makeStyles((theme: Theme) => ({
const useStyles = makeStyles((theme) => ({
dynamic: {
height: '100vh',
transition: 'opacity 1s ease-in-out',
backgroundColor: theme.palette.primary.main,
opacity: 0.9,
[theme.breakpoints.down(1024)]: {
height: 'calc(100vh - 64px)',
},
[theme.breakpoints.down('xs')]: {
height: 'calc(100vh - 56px)',
},
backgroundSize: 'cover',
backgroundPosition: '50% 0',
backgroundImage:
'linear-gradient(to right bottom, #51f2e4, #00cbea, #00a0ee, #0071e0, #0c39b7);',
},
}));

const BannerDynamicText = () => {
// the placeholder and the main image must refer to the same source
// we use this to request a consistent image vs a random one
const index = Math.floor(Math.random() * 999);

type BannerImageProps = {
visible?: boolean;
};

const BannerDynamicItems = ({ visible = true }: BannerImageProps) => {
const classes = useStyles();

const imageURL = `${imageServiceUrl}/${index}/`;
const placeholderURL = `${imageURL}?w=40`;

return (
<div className={classes.dynamic}>
<DynamicImage />
{/** prevent server-side rendering of the random banner images, which won't match with the client */}
<NoSsr>
<DynamicImage imageURL={imageURL} placeholderURL={placeholderURL} visible={visible} />
</NoSsr>
</div>
);
};

export default BannerDynamicText;
export default BannerDynamicItems;
65 changes: 58 additions & 7 deletions src/web/src/components/DynamicImage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import clsx from 'clsx';

import { imageServiceUrl } from '../config';

Expand All @@ -19,8 +21,22 @@ const useStyles = makeStyles(() => ({
minHeight: '100%',
maxHeight: '100%',
objectFit: 'cover',
transition: 'opacity 1s ease',
},
loadingImg: {
opacity: 0,
},
loadedImg: {
opacity: 1,
},
placeholderImg: {
filter: 'blur(15px)',
},
mainImg: {
zIndex: 99,
},
backdrop: {
zIndex: 100,
display: 'block',
height: '100%',
overflow: 'hidden',
Expand All @@ -32,24 +48,59 @@ const useStyles = makeStyles(() => ({
boxSizing: 'border-box',
margin: 0,
backgroundColor: '#000000',
opacity: '.75',
opacity: '.70',
},
}));

const DynamicImage = () => {
type DynamicImageProps = {
imageURL?: string;
placeholderURL?: string;
visible?: boolean;
};

// Define a series of sizes, and let the browser figure out which one to use
function createSrcset(imageSrc: string) {
const sizes = [200, 375, 450, 640, 750, 828, 1080, 1250, 1500, 1920, 2000];

return sizes.map((size) => `${imageSrc}?w=${size} ${size}w`).join(', ');
}

const DynamicImage = ({ imageURL, placeholderURL, visible = true }: DynamicImageProps) => {
const classes = useStyles();
const [loading, setLoading] = useState(true);

const imageSrc = imageURL ?? imageServiceUrl!;
const srcset = createSrcset(imageSrc);

const onBannerImageLoaded = () => {
setLoading(false);
};

return (
<picture>
{placeholderURL && (
<img
className={clsx(classes.img, classes.placeholderImg)}
src={placeholderURL}
alt="Telescope banner placeholder"
decoding="async"
sizes="100vw"
/>
)}
<img
src={imageServiceUrl}
className={classes.img}
alt=""
src={imageURL}
className={clsx(
classes.img,
classes.mainImg,
loading || !visible ? classes.loadingImg : classes.loadedImg
)}
alt="Telescope banner"
loading="eager"
decoding="async"
// Let the browser know that we want to fill the whole viewport width with this image
sizes="100vw"
// Define a series of sizes, and let the browser figure out which one to use
srcSet={`${imageServiceUrl}?w=200 200w, ${imageServiceUrl}?w=375 375w, ${imageServiceUrl}?w=450 450w, ${imageServiceUrl}?w=640 640w, ${imageServiceUrl}?w=750 750w, ${imageServiceUrl}?w=828 828w, ${imageServiceUrl}?w=1080 1080w, ${imageServiceUrl}?w=1250 1250w, ${imageServiceUrl}?w=1500 1500w, ${imageServiceUrl}?w=1920 1920w, ${imageServiceUrl}?w=2000 2000w`}
srcSet={srcset}
onLoad={onBannerImageLoaded}
/>
<div className={classes.backdrop} />
</picture>
Expand Down
9 changes: 6 additions & 3 deletions src/web/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import Posts from '../components/Posts';
import NavBar from '../components/NavBar';

const Home = () => {
const [bannerIsVisible, setBannerVisibility] = useState(true);
const [bannerVisible, setBannerVisibility] = useState(true);
return (
<>
<SEO pageTitle="Telescope" />
<Banner onVisibilityChange={(visible) => setBannerVisibility(visible)} />
<Banner
onVisibilityChange={(visible) => setBannerVisibility(visible)}
bannerVisible={bannerVisible}
/>
<main className="main">
<NavBar disabled={bannerIsVisible} />
<NavBar disabled={bannerVisible} />
<Posts />
</main>
</>
Expand Down
7 changes: 1 addition & 6 deletions src/web/src/pages/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import BasicInfo from '../components/SignUp/Forms/BasicInfo';
import GitHubAccount from '../components/SignUp/Forms/GitHubAccount';
import RSSFeeds from '../components/SignUp/Forms/RSSFeeds';
import Review from '../components/SignUp/Forms/Review';
import DynamicImage from '../components/DynamicImage';
import DynamicImage from '../components/BannerDynamicItems';

import { SignUpForm } from '../interfaces';
import formModels from '../components/SignUp/Schema/FormModel';
Expand Down Expand Up @@ -65,11 +65,6 @@ const useStyles = makeStyles((theme: Theme) =>
},
},
imageContainer: {
minHeight: '100vh',
width: '100vw',
position: 'absolute',
top: '0',
bottom: '0',
zIndex: -1,
[theme.breakpoints.down(600)]: {
display: 'none',
Expand Down

0 comments on commit 7fbecfc

Please sign in to comment.