migrate to react hooks
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import * as Model from "../model";
|
||||
import { Picture } from "./picture";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import { Picture } from "components/picture";
|
||||
|
||||
export interface Props {
|
||||
image: Model.Image;
|
||||
@@ -16,93 +15,99 @@ interface TouchStart {
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
touchStart?: TouchStart | null;
|
||||
}
|
||||
export const BigPicture: React.FC<Props> = ({
|
||||
image,
|
||||
onClose,
|
||||
showNext,
|
||||
showPrevious,
|
||||
width,
|
||||
}) => {
|
||||
const [touchStart, setTouchStart] = React.useState<TouchStart | null>(null);
|
||||
|
||||
export class BigPicture extends React.PureComponent<Props, State> {
|
||||
static displayName = "BigPicture";
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("keyup", this._onEscape as any);
|
||||
window.addEventListener("touchstart", this._onTouchStart as any);
|
||||
window.addEventListener("touchend", this._onTouchEnd as any);
|
||||
document.body.classList.add("no-scroll");
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("keyup", this._onEscape as any);
|
||||
window.removeEventListener("touchstart", this._onTouchStart as any);
|
||||
window.removeEventListener("touchend", this._onTouchEnd as any);
|
||||
document.body.classList.remove("no-scroll");
|
||||
}
|
||||
|
||||
render() {
|
||||
const scaleWidth = this.props.image.width / this.props.width;
|
||||
const scaleHeight = this.props.image.height / (window.innerHeight - 80);
|
||||
const scale = Math.max(scaleWidth, scaleHeight);
|
||||
|
||||
return (
|
||||
<div className="BigPicture">
|
||||
<Picture
|
||||
image={this.props.image}
|
||||
onClick={() => {}}
|
||||
height={this.props.image.height / scale}
|
||||
width={this.props.image.width / scale}
|
||||
/>
|
||||
<div className="BigPicture-footer">
|
||||
<a
|
||||
className="BigPicture-footerLink"
|
||||
href={`img/${this.props.image.src}`}
|
||||
target="_blank"
|
||||
>
|
||||
⬇ Download
|
||||
</a>
|
||||
<span
|
||||
className="BigPicture-footerLink"
|
||||
role="button"
|
||||
onClick={this.props.onClose}
|
||||
onKeyPress={this._keyPress}
|
||||
tabIndex={0}
|
||||
>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private _keyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
this.props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
private _onEscape = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
this.props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
private _onTouchStart = (e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
this.setState({ touchStart: { x: touch.screenX, y: touch.screenY } });
|
||||
};
|
||||
|
||||
private _onTouchEnd = (e: React.TouchEvent) => {
|
||||
const touch = e.changedTouches[0];
|
||||
const touchStart = this.state.touchStart as TouchStart;
|
||||
|
||||
const dx = touch.screenX - touchStart.x;
|
||||
|
||||
if (Math.abs(dx) / window.innerWidth > 0.05) {
|
||||
if (dx < 0) {
|
||||
this.props.showNext();
|
||||
} else {
|
||||
this.props.showPrevious();
|
||||
const onEscape = React.useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
this.setState({ touchStart: null });
|
||||
};
|
||||
}
|
||||
const onTouchStart = React.useCallback((e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
setTouchStart({ x: touch.screenX, y: touch.screenY });
|
||||
}, []);
|
||||
|
||||
const onTouchEnd = React.useCallback(
|
||||
(e: TouchEvent) => {
|
||||
if (!touchStart) {
|
||||
return;
|
||||
}
|
||||
const touch = e.changedTouches[0];
|
||||
const dx = touch.screenX - touchStart.x;
|
||||
|
||||
if (Math.abs(dx) / window.innerWidth > 0.05) {
|
||||
if (dx < 0) {
|
||||
showNext();
|
||||
} else {
|
||||
showPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
setTouchStart(null);
|
||||
},
|
||||
[showNext, showPrevious, touchStart]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("keyup", onEscape);
|
||||
window.addEventListener("touchstart", onTouchStart);
|
||||
window.addEventListener("touchend", onTouchEnd);
|
||||
document.body.classList.add("no-scroll");
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keyup", onEscape);
|
||||
window.removeEventListener("touchstart", onTouchStart);
|
||||
window.removeEventListener("touchend", onTouchEnd);
|
||||
document.body.classList.remove("no-scroll");
|
||||
};
|
||||
}, [onEscape, onTouchEnd, onTouchStart]);
|
||||
|
||||
const scaleWidth = image.width / width;
|
||||
const scaleHeight = image.height / (window.innerHeight - 80);
|
||||
const scale = Math.max(scaleWidth, scaleHeight);
|
||||
|
||||
return (
|
||||
<div className="BigPicture">
|
||||
<Picture
|
||||
image={image}
|
||||
onClick={() => {}}
|
||||
height={image.height / scale}
|
||||
width={image.width / scale}
|
||||
/>
|
||||
<div className="BigPicture-footer">
|
||||
<a
|
||||
className="BigPicture-footerLink"
|
||||
href={`img/${image.src}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
⬇ Download
|
||||
</a>
|
||||
<span
|
||||
className="BigPicture-footerLink"
|
||||
role="button"
|
||||
onClick={onClose}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Picture } from "./picture";
|
||||
import * as Model from "../model";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import { Picture } from "components/picture";
|
||||
|
||||
export interface Props {
|
||||
images: Model.Image[];
|
||||
@@ -24,72 +23,60 @@ interface BadList {
|
||||
badness: number;
|
||||
}
|
||||
|
||||
export class Grid extends React.PureComponent<Props, {}> {
|
||||
static displayName: string = "Grid";
|
||||
const badness = (row: Model.Image[], width: number, height: number): number => {
|
||||
const rowWidth = row.reduce((w, img) => w + img.width / img.height, 0);
|
||||
const rowHeight = width / rowWidth;
|
||||
|
||||
private gridHeight = 0;
|
||||
return (rowHeight - height) * (rowHeight - height);
|
||||
};
|
||||
|
||||
static badness = (
|
||||
row: Model.Image[],
|
||||
width: number,
|
||||
height: number
|
||||
): number => {
|
||||
const rowWidth = row.reduce((w, img) => w + img.width / img.height, 0);
|
||||
const rowHeight = width / rowWidth;
|
||||
export const Grid: React.FC<Props> = ({
|
||||
images,
|
||||
onImageSelected,
|
||||
pageBottom,
|
||||
width,
|
||||
height,
|
||||
}) => {
|
||||
const rowsMemo = React.useRef<Map<number, Map<number, BadList>>>(new Map());
|
||||
const targetRowHeight = width > 900 ? ROW_HEIGHT : MOBILE_ROW_HEIGHT;
|
||||
|
||||
return (rowHeight - height) * (rowHeight - height);
|
||||
};
|
||||
|
||||
// [width][idx] -> badness
|
||||
private rowsMemo: Map<number, Map<number, BadList>> = new Map();
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
React.useEffect(() => {
|
||||
// The memoized rows depend on the image list; clear when the set changes
|
||||
if (prevProps.images !== this.props.images) {
|
||||
this.rowsMemo.clear();
|
||||
}
|
||||
}
|
||||
rowsMemo.current.clear();
|
||||
}, [images]);
|
||||
|
||||
rows(idx: number): BadList {
|
||||
const targetHeight = this._rowHeight();
|
||||
|
||||
const memo = this.rowsMemo.get(this.props.width) ?? new Map();
|
||||
const rowsFor = (idx: number): BadList => {
|
||||
const memo = rowsMemo.current.get(width) ?? new Map();
|
||||
const maybeMemo = memo.get(idx);
|
||||
|
||||
if (maybeMemo) {
|
||||
return maybeMemo;
|
||||
}
|
||||
|
||||
if (idx === this.props.images.length) {
|
||||
if (idx === images.length) {
|
||||
return {
|
||||
splits: [],
|
||||
badness: 0,
|
||||
};
|
||||
}
|
||||
if (idx === this.props.images.length - 1) {
|
||||
const img = this.props.images[idx];
|
||||
const h = (img.height * this.props.width) / img.width;
|
||||
if (idx === images.length - 1) {
|
||||
const img = images[idx];
|
||||
const h = (img.height * width) / img.width;
|
||||
return {
|
||||
splits: [],
|
||||
badness: (targetHeight - h) * (targetHeight - h),
|
||||
badness: (targetRowHeight - h) * (targetRowHeight - h),
|
||||
};
|
||||
}
|
||||
|
||||
let bestIdx = -1;
|
||||
let leastBad = 1e50;
|
||||
let bestSplits: number[] = [];
|
||||
|
||||
for (let i = idx + 1; i <= this.props.images.length; i++) {
|
||||
const rowBadness = Grid.badness(
|
||||
this.props.images.slice(idx, i),
|
||||
this.props.width,
|
||||
targetHeight
|
||||
);
|
||||
const rest = this.rows(i);
|
||||
const badness = rest.badness + rowBadness;
|
||||
if (badness < leastBad) {
|
||||
leastBad = badness;
|
||||
bestIdx = i;
|
||||
for (let i = idx + 1; i <= images.length; i++) {
|
||||
const rowBadness = badness(images.slice(idx, i), width, targetRowHeight);
|
||||
const rest = rowsFor(i);
|
||||
const totalBadness = rest.badness + rowBadness;
|
||||
if (totalBadness < leastBad) {
|
||||
leastBad = totalBadness;
|
||||
bestSplits = [i, ...rest.splits];
|
||||
}
|
||||
}
|
||||
@@ -101,60 +88,57 @@ export class Grid extends React.PureComponent<Props, {}> {
|
||||
|
||||
memo.set(idx, badList);
|
||||
|
||||
if (!this.rowsMemo.has(this.props.width)) {
|
||||
this.rowsMemo.set(this.props.width, memo);
|
||||
if (!rowsMemo.current.has(width)) {
|
||||
rowsMemo.current.set(width, memo);
|
||||
}
|
||||
|
||||
return badList;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
this.gridHeight = 0;
|
||||
let gridHeight = 0;
|
||||
|
||||
const badList = this.rows(0);
|
||||
let lastBreak = 0;
|
||||
const rows: Row[] = badList.splits.map((split) => {
|
||||
const images = this.props.images.slice(lastBreak, split);
|
||||
lastBreak = split;
|
||||
const badList = rowsFor(0);
|
||||
let lastBreak = 0;
|
||||
const rows: Row[] = badList.splits.map((split) => {
|
||||
const slice = images.slice(lastBreak, split);
|
||||
lastBreak = split;
|
||||
|
||||
return {
|
||||
images,
|
||||
width: images.reduce((acc, img) => acc + img.width / img.height, 0),
|
||||
};
|
||||
});
|
||||
return {
|
||||
images: slice,
|
||||
width: slice.reduce((acc, img) => acc + img.width / img.height, 0),
|
||||
};
|
||||
});
|
||||
|
||||
const images = rows.map((row) => {
|
||||
const height = Math.min(this.props.height, this.props.width / row.width);
|
||||
|
||||
const pics = row.images.map((image) => {
|
||||
return (
|
||||
<Picture
|
||||
image={image}
|
||||
onClick={() => this.props.onImageSelected(image)}
|
||||
key={image.src}
|
||||
height={height}
|
||||
width={(image.width / image.height) * height}
|
||||
defer={this.gridHeight > this.props.pageBottom}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
this.gridHeight += height;
|
||||
const pictures = rows.map((row) => {
|
||||
const rowHeight = Math.min(height, width / row.width);
|
||||
|
||||
const pics = row.images.map((image) => {
|
||||
const scaledWidth = (image.width / image.height) * rowHeight;
|
||||
const defer = gridHeight > pageBottom;
|
||||
return (
|
||||
<div
|
||||
className="Grid-row"
|
||||
style={{ height: height + "px" }}
|
||||
key={row.images.map((image) => image.src).join(",")}
|
||||
>
|
||||
{pics}
|
||||
</div>
|
||||
<Picture
|
||||
image={image}
|
||||
onClick={() => onImageSelected(image)}
|
||||
key={image.src}
|
||||
height={rowHeight}
|
||||
width={scaledWidth}
|
||||
defer={defer}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="Grid">{images}</div>;
|
||||
}
|
||||
gridHeight += rowHeight;
|
||||
|
||||
private _rowHeight = (): number =>
|
||||
this.props.width > 900 ? ROW_HEIGHT : MOBILE_ROW_HEIGHT;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="Grid-row"
|
||||
style={{ height: rowHeight + "px" }}
|
||||
key={row.images.map((image) => image.src).join(",")}
|
||||
>
|
||||
{pics}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="Grid">{pictures}</div>;
|
||||
};
|
||||
|
||||
@@ -1,61 +1,48 @@
|
||||
import { Grid } from "./grid";
|
||||
import * as Model from "../model";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import { Grid } from "components/grid";
|
||||
|
||||
export interface Props {
|
||||
imageSet: Model.ImageSet;
|
||||
onImageSelected: (img: Model.Image) => void;
|
||||
onShowHome: () => void;
|
||||
setGridHeight: (height: number) => void;
|
||||
pageBottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class ImageSet extends React.PureComponent<Props, {}> {
|
||||
static displayName = "ImageSet";
|
||||
|
||||
private divRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="ImageSet" ref={this.divRef}>
|
||||
<h2>
|
||||
<span className="ImageSet-location">
|
||||
{this.props.imageSet.location}
|
||||
</span>
|
||||
<span className="ImageSet-description">
|
||||
{this.props.imageSet.description}
|
||||
</span>
|
||||
</h2>
|
||||
<Grid
|
||||
images={this.props.imageSet.images}
|
||||
onImageSelected={this.props.onImageSelected}
|
||||
pageBottom={this.props.pageBottom}
|
||||
width={this.props.width}
|
||||
height={this.props.height}
|
||||
/>
|
||||
<div className="ImageSet-navigation">
|
||||
<a href="#" onClick={this.props.onShowHome}>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
export const ImageSet: React.FC<Props> = ({
|
||||
imageSet,
|
||||
onImageSelected,
|
||||
onShowHome,
|
||||
pageBottom,
|
||||
width,
|
||||
height,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ImageSet">
|
||||
<h2>
|
||||
<span className="ImageSet-location">{imageSet.location}</span>
|
||||
<span className="ImageSet-description">{imageSet.description}</span>
|
||||
</h2>
|
||||
<Grid
|
||||
images={imageSet.images}
|
||||
onImageSelected={onImageSelected}
|
||||
pageBottom={pageBottom}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
<div className="ImageSet-navigation">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onShowHome();
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._setGridHeight();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._setGridHeight();
|
||||
}
|
||||
|
||||
private _setGridHeight = () => {
|
||||
if (this.divRef.current) {
|
||||
this.props.setGridHeight(this.divRef.current.clientHeight);
|
||||
}
|
||||
};
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as Model from "../model";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
|
||||
export interface Props {
|
||||
image: Model.Image;
|
||||
@@ -10,10 +9,6 @@ export interface Props {
|
||||
defer?: boolean;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isMounted: boolean;
|
||||
}
|
||||
|
||||
interface SrcSetInfo {
|
||||
jpeg: string;
|
||||
webp: string;
|
||||
@@ -21,46 +16,20 @@ interface SrcSetInfo {
|
||||
bestSrc: string;
|
||||
}
|
||||
|
||||
export class Picture extends React.PureComponent<Props, State> {
|
||||
static displayName = "Picture";
|
||||
export const Picture: React.FC<Props> = ({
|
||||
image,
|
||||
onClick,
|
||||
height,
|
||||
width,
|
||||
defer,
|
||||
}) => {
|
||||
const [isMounted, setIsMounted] = React.useState(false);
|
||||
|
||||
state: State = {
|
||||
isMounted: false,
|
||||
};
|
||||
React.useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({ isMounted: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.defer || !this.state.isMounted) {
|
||||
return (
|
||||
<div
|
||||
className="Picture-defer"
|
||||
style={{ width: this.props.width + "px" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const srcSet = this._srcset();
|
||||
|
||||
return (
|
||||
<picture>
|
||||
<source srcSet={srcSet.avif} type="image/avif" />
|
||||
<source srcSet={srcSet.webp} type="image/webp" />
|
||||
<source srcSet={srcSet.jpeg} type="image/jpeg" />
|
||||
<img
|
||||
id={this.props.image.src}
|
||||
onClick={this.props.onClick}
|
||||
src={srcSet.bestSrc}
|
||||
height={this.props.height + "px"}
|
||||
width={Math.floor(this.props.width) + "px"}
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
|
||||
private _srcset = (): SrcSetInfo => {
|
||||
const srcSet = React.useMemo(() => {
|
||||
const jpegSrcSet: string[] = [];
|
||||
const webpSrcSet: string[] = [];
|
||||
const avifSrcSet: string[] = [];
|
||||
@@ -68,15 +37,13 @@ export class Picture extends React.PureComponent<Props, State> {
|
||||
let bestScale = Infinity;
|
||||
|
||||
Model.SIZES.forEach((size) => {
|
||||
const width =
|
||||
this.props.image.width > this.props.image.height
|
||||
? size
|
||||
: (this.props.image.width / this.props.image.height) * size;
|
||||
const derivedWidth =
|
||||
image.width > image.height ? size : (image.width / image.height) * size;
|
||||
|
||||
const scale = width / this.props.width;
|
||||
const scale = derivedWidth / width;
|
||||
|
||||
if (scale >= 1 || size === 2400) {
|
||||
const jpeg = `img/${size}/${this.props.image.src}`;
|
||||
const jpeg = `img/${size}/${image.src}`;
|
||||
const webp = jpeg.replace("jpg", "webp");
|
||||
const avif = jpeg.replace("jpg", "avif");
|
||||
jpegSrcSet.push(`${jpeg} ${scale}x`);
|
||||
@@ -93,7 +60,27 @@ export class Picture extends React.PureComponent<Props, State> {
|
||||
jpeg: jpegSrcSet.join(","),
|
||||
webp: webpSrcSet.join(","),
|
||||
avif: avifSrcSet.join(","),
|
||||
bestSrc: `img/${bestSize}/${this.props.image.src}`,
|
||||
bestSrc: `img/${bestSize}/${image.src}`,
|
||||
};
|
||||
};
|
||||
}
|
||||
}, [image, width]);
|
||||
|
||||
if (defer || !isMounted) {
|
||||
return <div className="Picture-defer" style={{ width: width + "px" }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<picture>
|
||||
<source srcSet={srcSet.avif} type="image/avif" />
|
||||
<source srcSet={srcSet.webp} type="image/webp" />
|
||||
<source srcSet={srcSet.jpeg} type="image/jpeg" />
|
||||
<img
|
||||
id={image.src}
|
||||
onClick={onClick}
|
||||
src={srcSet.bestSrc}
|
||||
height={height + "px"}
|
||||
width={Math.floor(width) + "px"}
|
||||
alt=""
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,235 +1,255 @@
|
||||
import { BigPicture } from "./big_picture";
|
||||
import { ImageSet } from "./image_set";
|
||||
import { SetCover } from "./set_cover";
|
||||
import * as Model from "../model";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import { BigPicture } from "components/big_picture";
|
||||
import { ImageSet } from "components/image_set";
|
||||
import { SetCover } from "components/set_cover";
|
||||
|
||||
export interface Props {}
|
||||
|
||||
export interface State {
|
||||
data?: Model.Data | null;
|
||||
selectedImage?: Model.Image | null;
|
||||
selectedSet?: Model.ImageSet | null;
|
||||
gridHeights: number[];
|
||||
pageBottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
const viewWidth = (): number => {
|
||||
const widths = [
|
||||
window.innerWidth,
|
||||
window.outerWidth,
|
||||
document.documentElement?.clientWidth,
|
||||
document.body?.clientWidth,
|
||||
].filter((w): w is number => typeof w === "number" && w > 0 && isFinite(w));
|
||||
|
||||
export class Root extends React.PureComponent<Props, State> {
|
||||
static displayName = "Root";
|
||||
return widths.length > 0 ? Math.max(...widths) : 0;
|
||||
};
|
||||
|
||||
// innerWidth gets messed up when rotating phones from landscape -> portrait,
|
||||
// and chrome seems to not report innerWidth correctly when scrollbars are present
|
||||
private _viewWidth = (): number => {
|
||||
const widths = [
|
||||
window.innerWidth,
|
||||
window.outerWidth,
|
||||
document.documentElement?.clientWidth,
|
||||
document.body?.clientWidth,
|
||||
].filter((w): w is number => typeof w === "number" && w > 0 && isFinite(w));
|
||||
const viewHeight = (): number => {
|
||||
const heights = [
|
||||
window.innerHeight,
|
||||
window.outerHeight,
|
||||
document.documentElement?.clientHeight,
|
||||
document.body?.clientHeight,
|
||||
].filter((h): h is number => typeof h === "number" && h > 0 && isFinite(h));
|
||||
|
||||
// Use the largest reasonable value to avoid shrinking the grid when a single
|
||||
// measurement source temporarily reports something tiny.
|
||||
return widths.length > 0 ? Math.max(...widths) : 0;
|
||||
};
|
||||
return heights.length > 0 ? Math.max(...heights) : 0;
|
||||
};
|
||||
|
||||
private _viewHeight = (): number => {
|
||||
const heights = [
|
||||
window.innerHeight,
|
||||
window.outerHeight,
|
||||
document.documentElement?.clientHeight,
|
||||
document.body?.clientHeight,
|
||||
].filter((h): h is number => typeof h === "number" && h > 0 && isFinite(h));
|
||||
const formatHash = (set: Model.ImageSet) =>
|
||||
set.location.replace(/[^a-zA-Z0-9-_]/g, "-") +
|
||||
"-" +
|
||||
set.description.replace(/[^a-zA-Z0-9-_]/g, "-");
|
||||
|
||||
return heights.length > 0 ? Math.max(...heights) : 0;
|
||||
};
|
||||
export const Root: React.FC<Props> = () => {
|
||||
const [data, setData] = React.useState<Model.Data | null>(null);
|
||||
const [selectedImage, setSelectedImage] = React.useState<Model.Image | null>(
|
||||
null
|
||||
);
|
||||
const [selectedSet, setSelectedSet] = React.useState<Model.ImageSet | null>(
|
||||
null
|
||||
);
|
||||
const [dimensions, setDimensions] = React.useState(() => {
|
||||
const height = viewHeight();
|
||||
return {
|
||||
pageBottom: height + window.pageYOffset,
|
||||
width: viewWidth(),
|
||||
height,
|
||||
};
|
||||
});
|
||||
|
||||
state: State = {
|
||||
gridHeights: [],
|
||||
pageBottom: this._viewHeight() + window.pageYOffset,
|
||||
width: this._viewWidth(),
|
||||
height: this._viewHeight(),
|
||||
};
|
||||
const updateView = React.useCallback(() => {
|
||||
const height = viewHeight();
|
||||
setDimensions({
|
||||
pageBottom: height + window.pageYOffset,
|
||||
width: viewWidth(),
|
||||
height,
|
||||
});
|
||||
}, []);
|
||||
|
||||
componentDidMount() {
|
||||
const loadHash = React.useCallback(() => {
|
||||
if (window.location.hash.length === 0) {
|
||||
setSelectedImage(null);
|
||||
setSelectedSet(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = window.location.hash.slice(1);
|
||||
let nextImage: Model.Image | null = null;
|
||||
let nextSet: Model.ImageSet | null = null;
|
||||
|
||||
data.sets.forEach((set) => {
|
||||
if (formatHash(set) === hash) {
|
||||
nextSet = set;
|
||||
}
|
||||
|
||||
const image = set.images.find((img) => img.src === hash);
|
||||
if (image) {
|
||||
nextImage = image;
|
||||
nextSet = set;
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedImage(nextImage);
|
||||
setSelectedSet(nextSet);
|
||||
}, [data]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true;
|
||||
window
|
||||
.fetch(Model.dataUrl)
|
||||
.then((data) => data.json())
|
||||
.then((json) => this.setState({ data: json }))
|
||||
.then(this._loadHash)
|
||||
.then(this._onViewChange)
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
if (isMounted) {
|
||||
setData(json);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error("Error fetching data", e));
|
||||
|
||||
window.onresize = this._onViewChange;
|
||||
window.onscroll = this._onViewChange;
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
try {
|
||||
screen.orientation.onchange = this._onViewChange;
|
||||
} catch (e) {}
|
||||
React.useEffect(() => {
|
||||
if (data) {
|
||||
loadHash();
|
||||
}
|
||||
}, [data, loadHash]);
|
||||
|
||||
try {
|
||||
window.onorientationchange = this._onViewChange;
|
||||
} catch (e) {}
|
||||
React.useEffect(() => {
|
||||
const handlePopState = () => loadHash();
|
||||
|
||||
window.onpopstate = this._loadHash;
|
||||
}
|
||||
window.addEventListener("resize", updateView);
|
||||
window.addEventListener("scroll", updateView, { passive: true });
|
||||
window.addEventListener("orientationchange", updateView);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
private _renderSet(set: Model.ImageSet) {
|
||||
return (
|
||||
<ImageSet
|
||||
key={set.location + set.description}
|
||||
imageSet={set}
|
||||
pageBottom={this.state.pageBottom}
|
||||
setGridHeight={this._setGridHeight(0)}
|
||||
onImageSelected={this._onImageSelected}
|
||||
onShowHome={this._onHomeSelected}
|
||||
width={this.state.width}
|
||||
height={this.state.height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const orientation = (screen as any).orientation;
|
||||
if (orientation?.addEventListener) {
|
||||
orientation.addEventListener("change", updateView);
|
||||
}
|
||||
|
||||
updateView();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateView);
|
||||
window.removeEventListener("scroll", updateView);
|
||||
window.removeEventListener("orientationchange", updateView);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
if (orientation?.removeEventListener) {
|
||||
orientation.removeEventListener("change", updateView);
|
||||
}
|
||||
};
|
||||
}, [loadHash, updateView]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedSet) {
|
||||
document.title =
|
||||
selectedSet.location +
|
||||
" – " +
|
||||
selectedSet.description +
|
||||
" – Skiing - Aaron Gutierrez";
|
||||
} else {
|
||||
document.title = "Skiing - Aaron Gutierrez";
|
||||
}
|
||||
}, [selectedSet]);
|
||||
|
||||
const onImageSelected = React.useCallback(
|
||||
(img: Model.Image) => {
|
||||
if (selectedImage) {
|
||||
window.history.replaceState(null, "", `#${img.src}`);
|
||||
} else {
|
||||
window.history.pushState(null, "", `#${img.src}`);
|
||||
}
|
||||
setSelectedImage(img);
|
||||
},
|
||||
[selectedImage]
|
||||
);
|
||||
|
||||
const onSetSelected = React.useCallback((set: Model.ImageSet) => {
|
||||
setSelectedSet(set);
|
||||
window.history.pushState(null, "", `#${formatHash(set)}`);
|
||||
}, []);
|
||||
|
||||
const onHomeSelected = React.useCallback(() => {
|
||||
setSelectedSet(null);
|
||||
setSelectedImage(null);
|
||||
window.history.pushState(null, "", "#");
|
||||
}, []);
|
||||
|
||||
const showGrid = React.useCallback(() => {
|
||||
setSelectedImage(null);
|
||||
window.history.go(-1);
|
||||
if (selectedSet) {
|
||||
onSetSelected(selectedSet);
|
||||
}
|
||||
}, [onSetSelected, selectedSet]);
|
||||
|
||||
const showNextBigPicture = React.useCallback(() => {
|
||||
if (!selectedSet || !selectedImage) {
|
||||
return;
|
||||
}
|
||||
const images: Model.Image[] = selectedSet.images;
|
||||
const current = images.indexOf(selectedImage);
|
||||
const next = current + 1 >= images.length ? 0 : current + 1;
|
||||
onImageSelected(images[next]);
|
||||
}, [onImageSelected, selectedImage, selectedSet]);
|
||||
|
||||
const showPreviousBigPicture = React.useCallback(() => {
|
||||
if (!selectedSet || !selectedImage) {
|
||||
return;
|
||||
}
|
||||
const images: Model.Image[] = selectedSet.images;
|
||||
const current = images.indexOf(selectedImage);
|
||||
const previous = current - 1 < 0 ? images.length - 1 : current - 1;
|
||||
onImageSelected(images[previous]);
|
||||
}, [onImageSelected, selectedImage, selectedSet]);
|
||||
|
||||
const renderSets = () => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
if (selectedSet) {
|
||||
return (
|
||||
<ImageSet
|
||||
key={selectedSet.location + selectedSet.description}
|
||||
imageSet={selectedSet}
|
||||
pageBottom={dimensions.pageBottom}
|
||||
onImageSelected={onImageSelected}
|
||||
onShowHome={onHomeSelected}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private _renderSetCovers(sets: Model.ImageSet[]) {
|
||||
return (
|
||||
<div className="Root-setCovers">
|
||||
{sets.map((set) => (
|
||||
{data.sets.map((set) => (
|
||||
<SetCover
|
||||
key={set.location + set.description}
|
||||
imageSet={set}
|
||||
onClick={() => {
|
||||
this._onSetSelected(set);
|
||||
onSetSelected(set);
|
||||
scrollTo(0, 0);
|
||||
}}
|
||||
width={Math.min(this.state.width, 400)}
|
||||
width={Math.min(dimensions.width, 400)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const imageSets = this.state.data
|
||||
? this.state.selectedSet
|
||||
? this._renderSet(this.state.selectedSet)
|
||||
: this._renderSetCovers(this.state.data.sets)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="Root">
|
||||
{this._bigPicture()}
|
||||
<h1 onClick={this._onHomeSelected}>Aaron's Ski Pictures</h1>
|
||||
{imageSets}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private _bigPicture = () =>
|
||||
this.state.selectedImage ? (
|
||||
<BigPicture
|
||||
image={this.state.selectedImage}
|
||||
onClose={this._showGrid}
|
||||
showNext={this._showNextBigPicture}
|
||||
showPrevious={this._showPreviousBigPicture}
|
||||
width={this.state.width}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
private _loadHash = () => {
|
||||
if (window.location.hash.length > 0 && this.state.data) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
let selectedImage: Model.Image | null = null;
|
||||
let selectedSet: Model.ImageSet | null = null;
|
||||
|
||||
this.state.data.sets.forEach((set) => {
|
||||
if (this._setToHash(set) === hash) {
|
||||
selectedSet = set;
|
||||
}
|
||||
|
||||
const image = set.images.find((image) => image.src === hash);
|
||||
if (image) {
|
||||
selectedImage = image;
|
||||
selectedSet = set;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({ selectedImage, selectedSet });
|
||||
} else {
|
||||
this.setState({ selectedImage: null, selectedSet: null });
|
||||
}
|
||||
};
|
||||
|
||||
private _onViewChange = () => {
|
||||
this.setState({
|
||||
pageBottom: this._viewHeight() + window.pageYOffset,
|
||||
width: this._viewWidth(),
|
||||
height: this._viewHeight(),
|
||||
});
|
||||
};
|
||||
|
||||
private _onImageSelected = (img: Model.Image) => {
|
||||
if (this.state.selectedImage) {
|
||||
window.history.replaceState(null, "", `#${img.src}`);
|
||||
} else {
|
||||
window.history.pushState(null, "", `#${img.src}`);
|
||||
}
|
||||
this.setState({ selectedImage: img });
|
||||
};
|
||||
|
||||
private _onSetSelected = (set: Model.ImageSet) => {
|
||||
this.setState({ selectedSet: set });
|
||||
document.title =
|
||||
set.location + " – " + set.description + " – Skiing - Aaron Gutierrez";
|
||||
window.history.pushState(null, "", `#${this._setToHash(set)}`);
|
||||
};
|
||||
|
||||
private _onHomeSelected = () => {
|
||||
this.setState({
|
||||
selectedSet: null,
|
||||
selectedImage: null,
|
||||
});
|
||||
window.history.pushState(null, "", "#");
|
||||
document.title = "Skiing - Aaron Gutierrez";
|
||||
};
|
||||
|
||||
private _setToHash = (set: Model.ImageSet) =>
|
||||
set.location.replace(/[^a-zA-Z0-9-_]/g, "-") +
|
||||
"-" +
|
||||
set.description.replace(/[^a-zA-Z0-9-_]/g, "-");
|
||||
|
||||
private _showGrid = () => {
|
||||
this.setState({ selectedImage: null });
|
||||
window.history.go(-1);
|
||||
this._onSetSelected(this.state.selectedSet as Model.ImageSet);
|
||||
};
|
||||
|
||||
private _showNextBigPicture = () => {
|
||||
const images: Model.Image[] = this.state.selectedSet
|
||||
?.images as Model.Image[];
|
||||
const current = images.indexOf(this.state.selectedImage as Model.Image);
|
||||
const next = current + 1 >= images.length ? 0 : current + 1;
|
||||
this._onImageSelected(images[next]);
|
||||
};
|
||||
|
||||
private _showPreviousBigPicture = () => {
|
||||
const images: Model.Image[] = this.state.selectedSet
|
||||
?.images as Model.Image[];
|
||||
const current = images.indexOf(this.state.selectedImage as Model.Image);
|
||||
const previous = current - 1 < 0 ? images.length - 1 : current - 1;
|
||||
this._onImageSelected(images[previous]);
|
||||
};
|
||||
|
||||
private _setGridHeight = (grid: number) => (height: number) => {
|
||||
if (this.state.gridHeights[grid] === height) {
|
||||
return;
|
||||
}
|
||||
this.setState((state) => {
|
||||
const newGridHeights = [...state.gridHeights];
|
||||
newGridHeights[grid] = height;
|
||||
|
||||
return { gridHeights: newGridHeights };
|
||||
});
|
||||
};
|
||||
}
|
||||
return (
|
||||
<div className="Root">
|
||||
{selectedImage ? (
|
||||
<BigPicture
|
||||
image={selectedImage}
|
||||
onClose={showGrid}
|
||||
showNext={showNextBigPicture}
|
||||
showPrevious={showPreviousBigPicture}
|
||||
width={dimensions.width}
|
||||
/>
|
||||
) : null}
|
||||
<h1 onClick={onHomeSelected}>Aaron's Ski Pictures</h1>
|
||||
{renderSets()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Picture } from "./picture";
|
||||
import * as Model from "../model";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import { Picture } from "components/picture";
|
||||
|
||||
export interface Props {
|
||||
imageSet: Model.ImageSet;
|
||||
@@ -9,40 +8,30 @@ export interface Props {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface State {}
|
||||
export const SetCover: React.FC<Props> = ({ imageSet, onClick, width }) => {
|
||||
const coverImage = imageSet.images[0];
|
||||
const isTall = coverImage.height > coverImage.width;
|
||||
|
||||
export class SetCover extends React.PureComponent<Props, State> {
|
||||
static displayName = "SetCover";
|
||||
const height = isTall
|
||||
? width
|
||||
: (coverImage.height / coverImage.width) * width;
|
||||
|
||||
render() {
|
||||
const image = this.props.imageSet.images[0];
|
||||
const isTall = image.height > image.width;
|
||||
const normalizedWidth = isTall
|
||||
? (coverImage.width / coverImage.height) * width
|
||||
: width;
|
||||
|
||||
const height = isTall
|
||||
? this.props.width
|
||||
: (image.height / image.width) * this.props.width;
|
||||
|
||||
const width = isTall
|
||||
? (image.width / image.height) * this.props.width
|
||||
: this.props.width;
|
||||
|
||||
return (
|
||||
<div className="SetCover" onClick={this.props.onClick}>
|
||||
<Picture
|
||||
image={image}
|
||||
onClick={() => {}}
|
||||
height={height}
|
||||
width={width}
|
||||
/>
|
||||
<h2>
|
||||
<span className="SetCover-location">
|
||||
{this.props.imageSet.location}
|
||||
</span>
|
||||
<span className="SetCover-description">
|
||||
{this.props.imageSet.description}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="SetCover" onClick={onClick}>
|
||||
<Picture
|
||||
image={coverImage}
|
||||
onClick={() => {}}
|
||||
height={height}
|
||||
width={normalizedWidth}
|
||||
/>
|
||||
<h2>
|
||||
<span className="SetCover-location">{imageSet.location}</span>
|
||||
<span className="SetCover-description">{imageSet.description}</span>
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Root } from "./components/root";
|
||||
import { Root } from "components/root";
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"baseUrl": ".",
|
||||
"baseUrl": "./src",
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
@@ -26,7 +26,8 @@ module.exports = (env) => {
|
||||
|
||||
resolve: {
|
||||
// Add '.ts' and '.tsx' as resolvable extensions.
|
||||
extensions: [".ts", ".tsx", ".js", ".json"]
|
||||
extensions: [".ts", ".tsx", ".js", ".json"],
|
||||
modules: [path.resolve(__dirname, "src"), "node_modules"],
|
||||
},
|
||||
|
||||
module: {
|
||||
|
||||
Reference in New Issue
Block a user