migrate to react hooks

This commit is contained in:
2025-11-19 20:55:19 -08:00
parent 3c61426a12
commit 1cfcd8ff04
9 changed files with 495 additions and 522 deletions

View File

@@ -1,7 +1,6 @@
import * as Model from "../model";
import { Picture } from "./picture";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
import { Picture } from "components/picture";
export interface Props { export interface Props {
image: Model.Image; image: Model.Image;
@@ -16,53 +15,94 @@ interface TouchStart {
y: number; y: number;
} }
export interface State { export const BigPicture: React.FC<Props> = ({
touchStart?: TouchStart | null; image,
onClose,
showNext,
showPrevious,
width,
}) => {
const [touchStart, setTouchStart] = React.useState<TouchStart | null>(null);
const onEscape = React.useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
},
[onClose]
);
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();
}
} }
export class BigPicture extends React.PureComponent<Props, State> { setTouchStart(null);
static displayName = "BigPicture"; },
[showNext, showPrevious, touchStart]
);
componentDidMount() { React.useEffect(() => {
window.addEventListener("keyup", this._onEscape as any); window.addEventListener("keyup", onEscape);
window.addEventListener("touchstart", this._onTouchStart as any); window.addEventListener("touchstart", onTouchStart);
window.addEventListener("touchend", this._onTouchEnd as any); window.addEventListener("touchend", onTouchEnd);
document.body.classList.add("no-scroll"); document.body.classList.add("no-scroll");
}
componentWillUnmount() { return () => {
window.removeEventListener("keyup", this._onEscape as any); window.removeEventListener("keyup", onEscape);
window.removeEventListener("touchstart", this._onTouchStart as any); window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchend", this._onTouchEnd as any); window.removeEventListener("touchend", onTouchEnd);
document.body.classList.remove("no-scroll"); document.body.classList.remove("no-scroll");
} };
}, [onEscape, onTouchEnd, onTouchStart]);
render() { const scaleWidth = image.width / width;
const scaleWidth = this.props.image.width / this.props.width; const scaleHeight = image.height / (window.innerHeight - 80);
const scaleHeight = this.props.image.height / (window.innerHeight - 80);
const scale = Math.max(scaleWidth, scaleHeight); const scale = Math.max(scaleWidth, scaleHeight);
return ( return (
<div className="BigPicture"> <div className="BigPicture">
<Picture <Picture
image={this.props.image} image={image}
onClick={() => {}} onClick={() => {}}
height={this.props.image.height / scale} height={image.height / scale}
width={this.props.image.width / scale} width={image.width / scale}
/> />
<div className="BigPicture-footer"> <div className="BigPicture-footer">
<a <a
className="BigPicture-footerLink" className="BigPicture-footerLink"
href={`img/${this.props.image.src}`} href={`img/${image.src}`}
target="_blank" target="_blank"
rel="noreferrer"
> >
Download Download
</a> </a>
<span <span
className="BigPicture-footerLink" className="BigPicture-footerLink"
role="button" role="button"
onClick={this.props.onClose} onClick={onClose}
onKeyPress={this._keyPress} onKeyPress={(e) => {
if (e.key === "Enter") {
onClose();
}
}}
tabIndex={0} tabIndex={0}
> >
Close Close
@@ -70,39 +110,4 @@ export class BigPicture extends React.PureComponent<Props, State> {
</div> </div>
</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();
}
}
this.setState({ touchStart: null });
};
}

View File

@@ -1,7 +1,6 @@
import { Picture } from "./picture";
import * as Model from "../model";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
import { Picture } from "components/picture";
export interface Props { export interface Props {
images: Model.Image[]; images: Model.Image[];
@@ -24,72 +23,60 @@ interface BadList {
badness: number; badness: number;
} }
export class Grid extends React.PureComponent<Props, {}> { const badness = (row: Model.Image[], width: number, height: number): number => {
static displayName: string = "Grid";
private gridHeight = 0;
static badness = (
row: Model.Image[],
width: number,
height: number
): number => {
const rowWidth = row.reduce((w, img) => w + img.width / img.height, 0); const rowWidth = row.reduce((w, img) => w + img.width / img.height, 0);
const rowHeight = width / rowWidth; const rowHeight = width / rowWidth;
return (rowHeight - height) * (rowHeight - height); return (rowHeight - height) * (rowHeight - height);
}; };
// [width][idx] -> badness export const Grid: React.FC<Props> = ({
private rowsMemo: Map<number, Map<number, BadList>> = new Map(); 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;
componentDidUpdate(prevProps: Props) { React.useEffect(() => {
// The memoized rows depend on the image list; clear when the set changes // The memoized rows depend on the image list; clear when the set changes
if (prevProps.images !== this.props.images) { rowsMemo.current.clear();
this.rowsMemo.clear(); }, [images]);
}
}
rows(idx: number): BadList { const rowsFor = (idx: number): BadList => {
const targetHeight = this._rowHeight(); const memo = rowsMemo.current.get(width) ?? new Map();
const memo = this.rowsMemo.get(this.props.width) ?? new Map();
const maybeMemo = memo.get(idx); const maybeMemo = memo.get(idx);
if (maybeMemo) { if (maybeMemo) {
return maybeMemo; return maybeMemo;
} }
if (idx === this.props.images.length) { if (idx === images.length) {
return { return {
splits: [], splits: [],
badness: 0, badness: 0,
}; };
} }
if (idx === this.props.images.length - 1) { if (idx === images.length - 1) {
const img = this.props.images[idx]; const img = images[idx];
const h = (img.height * this.props.width) / img.width; const h = (img.height * width) / img.width;
return { return {
splits: [], splits: [],
badness: (targetHeight - h) * (targetHeight - h), badness: (targetRowHeight - h) * (targetRowHeight - h),
}; };
} }
let bestIdx = -1;
let leastBad = 1e50; let leastBad = 1e50;
let bestSplits: number[] = []; let bestSplits: number[] = [];
for (let i = idx + 1; i <= this.props.images.length; i++) { for (let i = idx + 1; i <= images.length; i++) {
const rowBadness = Grid.badness( const rowBadness = badness(images.slice(idx, i), width, targetRowHeight);
this.props.images.slice(idx, i), const rest = rowsFor(i);
this.props.width, const totalBadness = rest.badness + rowBadness;
targetHeight if (totalBadness < leastBad) {
); leastBad = totalBadness;
const rest = this.rows(i);
const badness = rest.badness + rowBadness;
if (badness < leastBad) {
leastBad = badness;
bestIdx = i;
bestSplits = [i, ...rest.splits]; bestSplits = [i, ...rest.splits];
} }
} }
@@ -101,50 +88,51 @@ export class Grid extends React.PureComponent<Props, {}> {
memo.set(idx, badList); memo.set(idx, badList);
if (!this.rowsMemo.has(this.props.width)) { if (!rowsMemo.current.has(width)) {
this.rowsMemo.set(this.props.width, memo); rowsMemo.current.set(width, memo);
} }
return badList; return badList;
} };
render() { let gridHeight = 0;
this.gridHeight = 0;
const badList = this.rows(0); const badList = rowsFor(0);
let lastBreak = 0; let lastBreak = 0;
const rows: Row[] = badList.splits.map((split) => { const rows: Row[] = badList.splits.map((split) => {
const images = this.props.images.slice(lastBreak, split); const slice = images.slice(lastBreak, split);
lastBreak = split; lastBreak = split;
return { return {
images, images: slice,
width: images.reduce((acc, img) => acc + img.width / img.height, 0), width: slice.reduce((acc, img) => acc + img.width / img.height, 0),
}; };
}); });
const images = rows.map((row) => { const pictures = rows.map((row) => {
const height = Math.min(this.props.height, this.props.width / row.width); const rowHeight = Math.min(height, width / row.width);
const pics = row.images.map((image) => { const pics = row.images.map((image) => {
const scaledWidth = (image.width / image.height) * rowHeight;
const defer = gridHeight > pageBottom;
return ( return (
<Picture <Picture
image={image} image={image}
onClick={() => this.props.onImageSelected(image)} onClick={() => onImageSelected(image)}
key={image.src} key={image.src}
height={height} height={rowHeight}
width={(image.width / image.height) * height} width={scaledWidth}
defer={this.gridHeight > this.props.pageBottom} defer={defer}
/> />
); );
}); });
this.gridHeight += height; gridHeight += rowHeight;
return ( return (
<div <div
className="Grid-row" className="Grid-row"
style={{ height: height + "px" }} style={{ height: rowHeight + "px" }}
key={row.images.map((image) => image.src).join(",")} key={row.images.map((image) => image.src).join(",")}
> >
{pics} {pics}
@@ -152,9 +140,5 @@ export class Grid extends React.PureComponent<Props, {}> {
); );
}); });
return <div className="Grid">{images}</div>; return <div className="Grid">{pictures}</div>;
} };
private _rowHeight = (): number =>
this.props.width > 900 ? ROW_HEIGHT : MOBILE_ROW_HEIGHT;
}

View File

@@ -1,61 +1,48 @@
import { Grid } from "./grid";
import * as Model from "../model";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
import { Grid } from "components/grid";
export interface Props { export interface Props {
imageSet: Model.ImageSet; imageSet: Model.ImageSet;
onImageSelected: (img: Model.Image) => void; onImageSelected: (img: Model.Image) => void;
onShowHome: () => void; onShowHome: () => void;
setGridHeight: (height: number) => void;
pageBottom: number; pageBottom: number;
width: number; width: number;
height: number; height: number;
} }
export class ImageSet extends React.PureComponent<Props, {}> { export const ImageSet: React.FC<Props> = ({
static displayName = "ImageSet"; imageSet,
onImageSelected,
private divRef = React.createRef<HTMLDivElement>(); onShowHome,
pageBottom,
render() { width,
height,
}) => {
return ( return (
<div className="ImageSet" ref={this.divRef}> <div className="ImageSet">
<h2> <h2>
<span className="ImageSet-location"> <span className="ImageSet-location">{imageSet.location}</span>
{this.props.imageSet.location} <span className="ImageSet-description">{imageSet.description}</span>
</span>
<span className="ImageSet-description">
{this.props.imageSet.description}
</span>
</h2> </h2>
<Grid <Grid
images={this.props.imageSet.images} images={imageSet.images}
onImageSelected={this.props.onImageSelected} onImageSelected={onImageSelected}
pageBottom={this.props.pageBottom} pageBottom={pageBottom}
width={this.props.width} width={width}
height={this.props.height} height={height}
/> />
<div className="ImageSet-navigation"> <div className="ImageSet-navigation">
<a href="#" onClick={this.props.onShowHome}> <a
href="#"
onClick={(e) => {
e.preventDefault();
onShowHome();
}}
>
Back Back
</a> </a>
</div> </div>
</div> </div>
); );
}
componentDidMount() {
this._setGridHeight();
}
componentDidUpdate() {
this._setGridHeight();
}
private _setGridHeight = () => {
if (this.divRef.current) {
this.props.setGridHeight(this.divRef.current.clientHeight);
}
}; };
}

View File

@@ -1,6 +1,5 @@
import * as Model from "../model";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
export interface Props { export interface Props {
image: Model.Image; image: Model.Image;
@@ -10,10 +9,6 @@ export interface Props {
defer?: boolean; defer?: boolean;
} }
export interface State {
isMounted: boolean;
}
interface SrcSetInfo { interface SrcSetInfo {
jpeg: string; jpeg: string;
webp: string; webp: string;
@@ -21,46 +16,20 @@ interface SrcSetInfo {
bestSrc: string; bestSrc: string;
} }
export class Picture extends React.PureComponent<Props, State> { export const Picture: React.FC<Props> = ({
static displayName = "Picture"; image,
onClick,
height,
width,
defer,
}) => {
const [isMounted, setIsMounted] = React.useState(false);
state: State = { React.useEffect(() => {
isMounted: false, setIsMounted(true);
}; }, []);
componentDidMount() { const srcSet = React.useMemo(() => {
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 jpegSrcSet: string[] = []; const jpegSrcSet: string[] = [];
const webpSrcSet: string[] = []; const webpSrcSet: string[] = [];
const avifSrcSet: string[] = []; const avifSrcSet: string[] = [];
@@ -68,15 +37,13 @@ export class Picture extends React.PureComponent<Props, State> {
let bestScale = Infinity; let bestScale = Infinity;
Model.SIZES.forEach((size) => { Model.SIZES.forEach((size) => {
const width = const derivedWidth =
this.props.image.width > this.props.image.height image.width > image.height ? size : (image.width / image.height) * size;
? size
: (this.props.image.width / this.props.image.height) * size;
const scale = width / this.props.width; const scale = derivedWidth / width;
if (scale >= 1 || size === 2400) { 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 webp = jpeg.replace("jpg", "webp");
const avif = jpeg.replace("jpg", "avif"); const avif = jpeg.replace("jpg", "avif");
jpegSrcSet.push(`${jpeg} ${scale}x`); jpegSrcSet.push(`${jpeg} ${scale}x`);
@@ -93,7 +60,27 @@ export class Picture extends React.PureComponent<Props, State> {
jpeg: jpegSrcSet.join(","), jpeg: jpegSrcSet.join(","),
webp: webpSrcSet.join(","), webp: webpSrcSet.join(","),
avif: avifSrcSet.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>
);
};

View File

@@ -1,28 +1,12 @@
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 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 Props {}
export interface State { const viewWidth = (): number => {
data?: Model.Data | null;
selectedImage?: Model.Image | null;
selectedSet?: Model.ImageSet | null;
gridHeights: number[];
pageBottom: number;
width: number;
height: number;
}
export class Root extends React.PureComponent<Props, State> {
static displayName = "Root";
// 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 = [ const widths = [
window.innerWidth, window.innerWidth,
window.outerWidth, window.outerWidth,
@@ -30,12 +14,10 @@ export class Root extends React.PureComponent<Props, State> {
document.body?.clientWidth, document.body?.clientWidth,
].filter((w): w is number => typeof w === "number" && w > 0 && isFinite(w)); ].filter((w): w is number => typeof w === "number" && w > 0 && isFinite(w));
// 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 widths.length > 0 ? Math.max(...widths) : 0;
}; };
private _viewHeight = (): number => { const viewHeight = (): number => {
const heights = [ const heights = [
window.innerHeight, window.innerHeight,
window.outerHeight, window.outerHeight,
@@ -46,190 +28,228 @@ export class Root extends React.PureComponent<Props, State> {
return heights.length > 0 ? Math.max(...heights) : 0; return heights.length > 0 ? Math.max(...heights) : 0;
}; };
state: State = { const formatHash = (set: Model.ImageSet) =>
gridHeights: [],
pageBottom: this._viewHeight() + window.pageYOffset,
width: this._viewWidth(),
height: this._viewHeight(),
};
componentDidMount() {
window
.fetch(Model.dataUrl)
.then((data) => data.json())
.then((json) => this.setState({ data: json }))
.then(this._loadHash)
.then(this._onViewChange)
.catch((e) => console.error("Error fetching data", e));
window.onresize = this._onViewChange;
window.onscroll = this._onViewChange;
try {
screen.orientation.onchange = this._onViewChange;
} catch (e) {}
try {
window.onorientationchange = this._onViewChange;
} catch (e) {}
window.onpopstate = this._loadHash;
}
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}
/>
);
}
private _renderSetCovers(sets: Model.ImageSet[]) {
return (
<div className="Root-setCovers">
{sets.map((set) => (
<SetCover
key={set.location + set.description}
imageSet={set}
onClick={() => {
this._onSetSelected(set);
scrollTo(0, 0);
}}
width={Math.min(this.state.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.location.replace(/[^a-zA-Z0-9-_]/g, "-") +
"-" + "-" +
set.description.replace(/[^a-zA-Z0-9-_]/g, "-"); set.description.replace(/[^a-zA-Z0-9-_]/g, "-");
private _showGrid = () => { export const Root: React.FC<Props> = () => {
this.setState({ selectedImage: null }); const [data, setData] = React.useState<Model.Data | null>(null);
window.history.go(-1); const [selectedImage, setSelectedImage] = React.useState<Model.Image | null>(
this._onSetSelected(this.state.selectedSet as Model.ImageSet); 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,
}; };
});
private _showNextBigPicture = () => { const updateView = React.useCallback(() => {
const images: Model.Image[] = this.state.selectedSet const height = viewHeight();
?.images as Model.Image[]; setDimensions({
const current = images.indexOf(this.state.selectedImage as Model.Image); pageBottom: height + window.pageYOffset,
const next = current + 1 >= images.length ? 0 : current + 1; width: viewWidth(),
this._onImageSelected(images[next]); height,
}; });
}, []);
private _showPreviousBigPicture = () => { const loadHash = React.useCallback(() => {
const images: Model.Image[] = this.state.selectedSet if (window.location.hash.length === 0) {
?.images as Model.Image[]; setSelectedImage(null);
const current = images.indexOf(this.state.selectedImage as Model.Image); setSelectedSet(null);
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; return;
} }
this.setState((state) => {
const newGridHeights = [...state.gridHeights];
newGridHeights[grid] = height;
return { gridHeights: newGridHeights }; 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((response) => response.json())
.then((json) => {
if (isMounted) {
setData(json);
}
})
.catch((e) => console.error("Error fetching data", e));
return () => {
isMounted = false;
};
}, []);
React.useEffect(() => {
if (data) {
loadHash();
}
}, [data, loadHash]);
React.useEffect(() => {
const handlePopState = () => loadHash();
window.addEventListener("resize", updateView);
window.addEventListener("scroll", updateView, { passive: true });
window.addEventListener("orientationchange", updateView);
window.addEventListener("popstate", handlePopState);
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}
/>
);
}
return (
<div className="Root-setCovers">
{data.sets.map((set) => (
<SetCover
key={set.location + set.description}
imageSet={set}
onClick={() => {
onSetSelected(set);
scrollTo(0, 0);
}}
width={Math.min(dimensions.width, 400)}
/>
))}
</div>
);
};
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>
);
};

View File

@@ -1,7 +1,6 @@
import { Picture } from "./picture";
import * as Model from "../model";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
import { Picture } from "components/picture";
export interface Props { export interface Props {
imageSet: Model.ImageSet; imageSet: Model.ImageSet;
@@ -9,40 +8,30 @@ export interface Props {
width: number; width: number;
} }
export interface State {} export const SetCover: React.FC<Props> = ({ imageSet, onClick, width }) => {
const coverImage = imageSet.images[0];
export class SetCover extends React.PureComponent<Props, State> { const isTall = coverImage.height > coverImage.width;
static displayName = "SetCover";
render() {
const image = this.props.imageSet.images[0];
const isTall = image.height > image.width;
const height = isTall const height = isTall
? this.props.width ? width
: (image.height / image.width) * this.props.width; : (coverImage.height / coverImage.width) * width;
const width = isTall const normalizedWidth = isTall
? (image.width / image.height) * this.props.width ? (coverImage.width / coverImage.height) * width
: this.props.width; : width;
return ( return (
<div className="SetCover" onClick={this.props.onClick}> <div className="SetCover" onClick={onClick}>
<Picture <Picture
image={image} image={coverImage}
onClick={() => {}} onClick={() => {}}
height={height} height={height}
width={width} width={normalizedWidth}
/> />
<h2> <h2>
<span className="SetCover-location"> <span className="SetCover-location">{imageSet.location}</span>
{this.props.imageSet.location} <span className="SetCover-description">{imageSet.description}</span>
</span>
<span className="SetCover-description">
{this.props.imageSet.description}
</span>
</h2> </h2>
</div> </div>
); );
} };
}

View File

@@ -1,4 +1,4 @@
import { Root } from "./components/root"; import { Root } from "components/root";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";

View File

@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/", "outDir": "./dist/",
"baseUrl": ".", "baseUrl": "./src",
"sourceMap": true, "sourceMap": true,
"noImplicitAny": true, "noImplicitAny": true,
"strictNullChecks": true, "strictNullChecks": true,

View File

@@ -26,7 +26,8 @@ module.exports = (env) => {
resolve: { resolve: {
// Add '.ts' and '.tsx' as resolvable extensions. // 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: { module: {