Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
445f95340a
|
|||
|
e488e76a64
|
|||
|
331caf2d6f
|
|||
|
1cfcd8ff04
|
|||
|
3c61426a12
|
|||
|
c1dc4aaa31
|
|||
|
d3106b9a99
|
|||
|
e42dc4ada6
|
|||
|
a6d9abd0e1
|
|||
|
cda31bea4e
|
|||
|
ad07adfb5f
|
|||
|
f9c0b9ae09
|
|||
|
ad1c7cac0f
|
|||
|
c6c651f0b4
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
dist/
|
dist/
|
||||||
img/
|
img/*
|
||||||
|
!img/convert.sh
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
venv/
|
||||||
|
.idea/
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
function make_jpg {
|
function make_jpg {
|
||||||
if [ ! -f $2/$1 ]; then
|
if [ ! -f $2/$1 ]; then
|
||||||
echo "Converting $2/$1"
|
echo "Converting $2/$1"
|
||||||
convert $1 -resize $2x$2 -quality 30 $2/$1
|
magick $1 -resize $2x$2 -quality 30 $2/$1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +14,15 @@ function make_webp {
|
|||||||
NAME="$(basename $1 .jpg).webp"
|
NAME="$(basename $1 .jpg).webp"
|
||||||
if [ ! -f $2/$NAME ]; then
|
if [ ! -f $2/$NAME ]; then
|
||||||
echo "Converting $2/$NAME"
|
echo "Converting $2/$NAME"
|
||||||
convert $1 -resize $2x$2 -quality 30 $2/$NAME
|
magick $1 -resize $2x$2 -quality $3 $2/$NAME
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function make_avif {
|
||||||
|
NAME="$(basename $1 .jpg).avif"
|
||||||
|
if [ ! -f $2/$NAME ]; then
|
||||||
|
echo "Converting $2/$NAME"
|
||||||
|
magick $1 -resize $2x$2 -quality $3 $2/$NAME
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,19 +33,29 @@ for f in original/*.jpg; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
for img in *.jpg; do
|
for img in *.jpg; do
|
||||||
make_jpg $img 2400
|
make_jpg $img 2400 &
|
||||||
make_jpg $img 1600
|
make_jpg $img 1600 &
|
||||||
make_jpg $img 1200
|
make_jpg $img 1200 &
|
||||||
make_jpg $img 800
|
make_jpg $img 800 &
|
||||||
make_jpg $img 600
|
make_jpg $img 600 &
|
||||||
make_jpg $img 400
|
make_jpg $img 400 &
|
||||||
make_jpg $img 200
|
make_jpg $img 200 &
|
||||||
|
|
||||||
make_webp $img 2400
|
make_webp $img 2400 50 &
|
||||||
make_webp $img 1600
|
make_webp $img 1600 50 &
|
||||||
make_webp $img 1200
|
make_webp $img 1200 50 &
|
||||||
make_webp $img 800
|
make_webp $img 800 40 &
|
||||||
make_webp $img 600
|
make_webp $img 600 40 &
|
||||||
make_webp $img 400
|
make_webp $img 400 40 &
|
||||||
make_webp $img 200
|
make_webp $img 200 40 &
|
||||||
|
|
||||||
|
make_avif $img 2400 70 &
|
||||||
|
make_avif $img 1600 70 &
|
||||||
|
make_avif $img 1200 70 &
|
||||||
|
make_avif $img 800 60 &
|
||||||
|
make_avif $img 600 60 &
|
||||||
|
make_avif $img 400 50 &
|
||||||
|
make_avif $img 200 50 &
|
||||||
|
|
||||||
|
wait
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>Skiing - Aaron Gutierrez</title>
|
<title>Skiing - Aaron Gutierrez</title>
|
||||||
<link rel="stylesheet" href="site.css">
|
<link rel="stylesheet" href="site.css">
|
||||||
<meta charshet="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
6876
package-lock.json
generated
6876
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -15,18 +15,18 @@
|
|||||||
"author": "Aaron Gutierrez",
|
"author": "Aaron Gutierrez",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "^18.0.26",
|
"react": "^19.1.0",
|
||||||
"@types/react-dom": "^18.0.10",
|
"react-dom": "^19.1.0"
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.16.10",
|
"@types/react": "19.1.0",
|
||||||
"source-map-loader": "^4.0.1",
|
"@types/react-dom": "19.1.0",
|
||||||
"ts-loader": "^9.4.2",
|
"esbuild": "^0.25.4",
|
||||||
"typescript": "^4.9.4",
|
"source-map-loader": "^5.0.0",
|
||||||
"webpack": "^5.66.0",
|
"ts-loader": "^9.5.2",
|
||||||
"webpack-cli": "^5.0.1",
|
"typescript": "^5.8.3",
|
||||||
"webpack-dev-server": "^4.7.3"
|
"webpack": "^5.99.9",
|
||||||
|
"webpack-cli": "^6.0.1",
|
||||||
|
"webpack-dev-server": "^5.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
pub.py
3
pub.py
@@ -4,7 +4,6 @@ import argparse
|
|||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from functools import cache
|
from functools import cache
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
@@ -20,6 +19,7 @@ cloudfront = session.client('cloudfront')
|
|||||||
|
|
||||||
|
|
||||||
TYPE_MAP = {
|
TYPE_MAP = {
|
||||||
|
'avif': 'image/avif',
|
||||||
'css': 'text/css',
|
'css': 'text/css',
|
||||||
'gif': 'image/gif',
|
'gif': 'image/gif',
|
||||||
'html': 'text/html; charset=utf8',
|
'html': 'text/html; charset=utf8',
|
||||||
@@ -28,7 +28,6 @@ TYPE_MAP = {
|
|||||||
'json': 'application/json',
|
'json': 'application/json',
|
||||||
'png': 'image/png',
|
'png': 'image/png',
|
||||||
'webp': 'image/webp',
|
'webp': 'image/webp',
|
||||||
'avif': 'image/avif',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
51
site.css
51
site.css
@@ -118,10 +118,56 @@ a:hover {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.BigPicture-viewport {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BigPicture-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BigPicture-track.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BigPicture-frame {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BigPicture-frame.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BigPicture-imageWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BigPicture-zoomTarget {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
.BigPicture img {
|
.BigPicture img {
|
||||||
transition-duration: 0.2s;
|
transition-duration: 0.2s;
|
||||||
transition-timing-function: ease-in-quart;
|
transition-timing-function: ease-in-out;
|
||||||
transition-property: height, width;
|
transition-property: height, width, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
.BigPicture-footer {
|
.BigPicture-footer {
|
||||||
@@ -129,6 +175,7 @@ a:hover {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
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;
|
||||||
|
previousImage: Model.Image;
|
||||||
|
nextImage: Model.Image;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
showNext: () => void;
|
showNext: () => void;
|
||||||
showPrevious: () => void;
|
showPrevious: () => void;
|
||||||
@@ -16,93 +17,457 @@ interface TouchStart {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
interface PinchState {
|
||||||
touchStart?: TouchStart | null;
|
distance: number;
|
||||||
|
startZoom: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BigPicture extends React.PureComponent<Props, State> {
|
interface Point {
|
||||||
static displayName = "BigPicture";
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
export const BigPicture: React.FC<Props> = ({
|
||||||
window.addEventListener("keyup", this._onEscape as any);
|
image,
|
||||||
window.addEventListener("touchstart", this._onTouchStart as any);
|
previousImage,
|
||||||
window.addEventListener("touchend", this._onTouchEnd as any);
|
nextImage,
|
||||||
|
onClose,
|
||||||
|
showNext,
|
||||||
|
showPrevious,
|
||||||
|
width,
|
||||||
|
}) => {
|
||||||
|
const viewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const [touchStart, setTouchStart] = React.useState<TouchStart | null>(null);
|
||||||
|
const [dragOffset, setDragOffset] = React.useState(0);
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const [animating, setAnimating] = React.useState(false);
|
||||||
|
const [isSnapping, setIsSnapping] = React.useState(false);
|
||||||
|
const [zoom, setZoom] = React.useState(1);
|
||||||
|
const [pan, setPan] = React.useState<Point>({ x: 0, y: 0 });
|
||||||
|
const [panStart, setPanStart] = React.useState<Point | null>(null);
|
||||||
|
const [pinchState, setPinchState] = React.useState<PinchState | null>(null);
|
||||||
|
const [isPointerPanning, setIsPointerPanning] = React.useState(false);
|
||||||
|
const tapStartRef = React.useRef<Point | null>(null);
|
||||||
|
const lastTapRef = React.useRef<{ time: number; x: number; y: number } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const onEscape = React.useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
const goNext = React.useCallback(() => {
|
||||||
|
showNext();
|
||||||
|
}, [showNext]);
|
||||||
|
|
||||||
|
const goPrevious = React.useCallback(() => {
|
||||||
|
showPrevious();
|
||||||
|
}, [showPrevious]);
|
||||||
|
|
||||||
|
const getViewportCenter = React.useCallback((): Point | null => {
|
||||||
|
const node = viewportRef.current;
|
||||||
|
if (!node) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return { x: rect.width / 2, y: rect.height / 2 };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clampPan = React.useCallback(
|
||||||
|
(nextPan: Point, nextZoom?: number) => {
|
||||||
|
const node = viewportRef.current;
|
||||||
|
if (!node) {
|
||||||
|
return nextPan;
|
||||||
|
}
|
||||||
|
const z = nextZoom ?? zoom;
|
||||||
|
if (z <= 1) {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
const viewportRect = node.getBoundingClientRect();
|
||||||
|
const viewportWidth = viewportRect.width;
|
||||||
|
const viewportHeight = viewportRect.height;
|
||||||
|
const imgScaleWidth = image.width / width;
|
||||||
|
const imgScaleHeight = image.height / (window.innerHeight - 80);
|
||||||
|
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
|
||||||
|
const displayWidth = image.width / imgScale;
|
||||||
|
const displayHeight = image.height / imgScale;
|
||||||
|
const maxPanX = Math.max(0, (displayWidth * z - viewportWidth) / 2);
|
||||||
|
const maxPanY = Math.max(0, (displayHeight * z - viewportHeight) / 2);
|
||||||
|
return {
|
||||||
|
x: Math.min(maxPanX, Math.max(-maxPanX, nextPan.x)),
|
||||||
|
y: Math.min(maxPanY, Math.max(-maxPanY, nextPan.y)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[image.height, image.width, width, zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyZoomAt = React.useCallback(
|
||||||
|
(nextZoom: number, focalPoint?: Point) => {
|
||||||
|
const clampedZoom = Math.min(4, Math.max(1, nextZoom));
|
||||||
|
const center = getViewportCenter();
|
||||||
|
if (!center) {
|
||||||
|
setZoom(clampedZoom);
|
||||||
|
setPan({ x: 0, y: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const focus = focalPoint ?? center;
|
||||||
|
const ratio = clampedZoom / zoom;
|
||||||
|
const nextPan = {
|
||||||
|
x: pan.x * ratio + (focus.x - center.x) * (1 - ratio),
|
||||||
|
y: pan.y * ratio + (focus.y - center.y) * (1 - ratio),
|
||||||
|
};
|
||||||
|
setZoom(clampedZoom);
|
||||||
|
setPan(clampPan(nextPan, clampedZoom));
|
||||||
|
},
|
||||||
|
[clampPan, getViewportCenter, pan.x, pan.y, zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startSwipe = React.useCallback((touch: React.Touch) => {
|
||||||
|
setTouchStart({ x: touch.clientX, y: touch.clientY });
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragOffset(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchStart = React.useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
tapStartRef.current = null;
|
||||||
|
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
const [a, b] = [e.touches[0], e.touches[1]];
|
||||||
|
const dx = a.clientX - b.clientX;
|
||||||
|
const dy = a.clientY - b.clientY;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
setPinchState({ distance, startZoom: zoom });
|
||||||
|
setIsDragging(false);
|
||||||
|
setTouchStart(null);
|
||||||
|
setPanStart(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoom > 1) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setPanStart({ x: touch.clientX, y: touch.clientY });
|
||||||
|
setIsDragging(false);
|
||||||
|
setTouchStart(null);
|
||||||
|
tapStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
startSwipe(touch);
|
||||||
|
tapStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||||
|
},
|
||||||
|
[startSwipe, zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchEnd = React.useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const tapStart = tapStartRef.current;
|
||||||
|
const now = Date.now();
|
||||||
|
const isTap =
|
||||||
|
tapStart &&
|
||||||
|
Math.hypot(tapStart.x - touch.clientX, tapStart.y - touch.clientY) < 10;
|
||||||
|
const lastTap = lastTapRef.current;
|
||||||
|
|
||||||
|
if (isTap && lastTap && now - lastTap.time < 350) {
|
||||||
|
const distance = Math.hypot(lastTap.x - touch.clientX, lastTap.y - touch.clientY);
|
||||||
|
if (distance < 40) {
|
||||||
|
const rect = viewportRef.current?.getBoundingClientRect();
|
||||||
|
const focal = rect
|
||||||
|
? { x: touch.clientX - rect.left, y: touch.clientY - rect.top }
|
||||||
|
: undefined;
|
||||||
|
const fitScale = Math.max(
|
||||||
|
image.width / width,
|
||||||
|
image.height / (window.innerHeight - 80)
|
||||||
|
);
|
||||||
|
const targetZoom = zoom > 1.05 ? 1 : Math.min(4, fitScale);
|
||||||
|
applyZoomAt(targetZoom, focal);
|
||||||
|
setIsDragging(false);
|
||||||
|
setTouchStart(null);
|
||||||
|
setPanStart(null);
|
||||||
|
tapStartRef.current = null;
|
||||||
|
lastTapRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTap) {
|
||||||
|
lastTapRef.current = { time: now, x: touch.clientX, y: touch.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinchState) {
|
||||||
|
setPinchState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoom > 1) {
|
||||||
|
setPanStart(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!touchStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dx = touch.clientX - touchStart.x;
|
||||||
|
const threshold = Math.max(50, width * 0.08);
|
||||||
|
const movedEnough = Math.abs(dx) > threshold;
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
setTouchStart(null);
|
||||||
|
|
||||||
|
if (movedEnough) {
|
||||||
|
const target = dx < 0 ? -width : width;
|
||||||
|
setAnimating(true);
|
||||||
|
setDragOffset(target);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsSnapping(true);
|
||||||
|
if (dx < 0) {
|
||||||
|
goNext();
|
||||||
|
} else {
|
||||||
|
goPrevious();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setDragOffset(0);
|
||||||
|
setAnimating(false);
|
||||||
|
requestAnimationFrame(() => setIsSnapping(false));
|
||||||
|
});
|
||||||
|
}, 220);
|
||||||
|
} else {
|
||||||
|
setDragOffset(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyZoomAt, goNext, goPrevious, image.height, image.width, pinchState, touchStart, width, zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchMove = React.useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
if (pinchState && e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
const [a, b] = [e.touches[0], e.touches[1]];
|
||||||
|
const dx = a.clientX - b.clientX;
|
||||||
|
const dy = a.clientY - b.clientY;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
const rect = viewportRef.current?.getBoundingClientRect();
|
||||||
|
const midpoint = {
|
||||||
|
x: (a.clientX + b.clientX) / 2 - (rect?.left ?? 0),
|
||||||
|
y: (a.clientY + b.clientY) / 2 - (rect?.top ?? 0),
|
||||||
|
};
|
||||||
|
const ratio = distance / pinchState.distance;
|
||||||
|
applyZoomAt(pinchState.startZoom * ratio, midpoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoom > 1 && panStart) {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setPan((prev) =>
|
||||||
|
clampPan(
|
||||||
|
{
|
||||||
|
x: prev.x + (touch.clientX - panStart.x),
|
||||||
|
y: prev.y + (touch.clientY - panStart.y),
|
||||||
|
},
|
||||||
|
zoom
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setPanStart({ x: touch.clientX, y: touch.clientY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!touchStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - touchStart.x;
|
||||||
|
// Allow slight vertical movement but ignore large vertical swipes to avoid accidental nav
|
||||||
|
const dy = Math.abs(touch.clientY - touchStart.y);
|
||||||
|
if (dy > Math.abs(dx) * 1.5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDragOffset(dx);
|
||||||
|
},
|
||||||
|
[applyZoomAt, clampPan, panStart, pinchState, touchStart, zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.addEventListener("keyup", onEscape);
|
||||||
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);
|
document.body.classList.remove("no-scroll");
|
||||||
window.removeEventListener("touchend", this._onTouchEnd as any);
|
};
|
||||||
document.body.classList.remove("no-scroll");
|
}, [onEscape]);
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
React.useEffect(() => {
|
||||||
const scaleWidth = this.props.image.width / this.props.width;
|
setZoom(1);
|
||||||
const scaleHeight = this.props.image.height / (window.innerHeight - 80);
|
setPan({ x: 0, y: 0 });
|
||||||
const scale = Math.max(scaleWidth, scaleHeight);
|
}, [image.src]);
|
||||||
|
|
||||||
return (
|
const onWheel = React.useCallback(
|
||||||
<div className="BigPicture">
|
(e: WheelEvent) => {
|
||||||
<Picture
|
if (!viewportRef.current) {
|
||||||
image={this.props.image}
|
return;
|
||||||
onClick={() => {}}
|
}
|
||||||
height={this.props.image.height / scale}
|
const rect = viewportRef.current.getBoundingClientRect();
|
||||||
width={this.props.image.width / scale}
|
const focal = {
|
||||||
/>
|
x: e.clientX - rect.left,
|
||||||
<div className="BigPicture-footer">
|
y: e.clientY - rect.top,
|
||||||
<a
|
};
|
||||||
className="BigPicture-footerLink"
|
const delta = -e.deltaY * (e.ctrlKey ? 0.0025 : 0.0015);
|
||||||
href={`img/${this.props.image.src}`}
|
const nextZoom = zoom * (1 + delta);
|
||||||
target="_blank"
|
if (Math.abs(delta) > 0.0001) {
|
||||||
>
|
e.preventDefault();
|
||||||
⬇ Download
|
}
|
||||||
</a>
|
applyZoomAt(nextZoom, focal);
|
||||||
<span
|
},
|
||||||
className="BigPicture-footerLink"
|
[applyZoomAt, zoom]
|
||||||
role="button"
|
);
|
||||||
onClick={this.props.onClose}
|
|
||||||
onKeyPress={this._keyPress}
|
React.useEffect(() => {
|
||||||
tabIndex={0}
|
const node = viewportRef.current;
|
||||||
>
|
if (!node) {
|
||||||
Close
|
return;
|
||||||
</span>
|
}
|
||||||
|
node.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
node.removeEventListener("wheel", onWheel);
|
||||||
|
};
|
||||||
|
}, [onWheel]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isPointerPanning || zoom <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPan((prev) =>
|
||||||
|
clampPan(
|
||||||
|
{
|
||||||
|
x: prev.x + e.movementX,
|
||||||
|
y: prev.y + e.movementY,
|
||||||
|
},
|
||||||
|
zoom
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
setIsPointerPanning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
}, [clampPan, isPointerPanning, zoom]);
|
||||||
|
|
||||||
|
const onMouseDown = React.useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (zoom <= 1 || e.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
setIsPointerPanning(true);
|
||||||
|
},
|
||||||
|
[zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const slideWidth = width;
|
||||||
|
const trackBase = -slideWidth + dragOffset;
|
||||||
|
const trackStyle: React.CSSProperties = {
|
||||||
|
width: `${slideWidth * 3}px`,
|
||||||
|
transform: `translate3d(${trackBase}px, 0, 0)`,
|
||||||
|
transition: isDragging || isSnapping ? "none" : "transform 0.25s ease",
|
||||||
|
};
|
||||||
|
const slideStyle: React.CSSProperties = {
|
||||||
|
width: `${slideWidth}px`,
|
||||||
|
flex: `0 0 ${slideWidth}px`,
|
||||||
|
opacity: animating ? 0.98 : 1,
|
||||||
|
transition: isDragging ? "none" : "opacity 0.2s ease",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="BigPicture">
|
||||||
|
<div
|
||||||
|
className="BigPicture-viewport"
|
||||||
|
ref={viewportRef}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`BigPicture-track${isDragging ? " is-dragging" : ""}`}
|
||||||
|
style={trackStyle}
|
||||||
|
>
|
||||||
|
{[previousImage, image, nextImage].map((img) => {
|
||||||
|
const imgScaleWidth = img.width / width;
|
||||||
|
const imgScaleHeight = img.height / (window.innerHeight - 80);
|
||||||
|
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
|
||||||
|
const isCurrent = img.src === image.src;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="BigPicture-frame"
|
||||||
|
key={img.src}
|
||||||
|
style={slideStyle}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="BigPicture-imageWrapper"
|
||||||
|
style={
|
||||||
|
isCurrent
|
||||||
|
? { transform: `translate3d(${pan.x}px, ${pan.y}px, 0)` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="BigPicture-zoomTarget"
|
||||||
|
style={isCurrent ? { transform: `scale(${zoom})` } : undefined}
|
||||||
|
>
|
||||||
|
<Picture
|
||||||
|
image={img}
|
||||||
|
onClick={() => {}}
|
||||||
|
height={img.height / imgScale}
|
||||||
|
width={img.width / imgScale}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="BigPicture-footer">
|
||||||
}
|
<a
|
||||||
|
className="BigPicture-footerLink"
|
||||||
private _keyPress = (e: React.KeyboardEvent) => {
|
href={`img/${image.src}`}
|
||||||
if (e.key === "Enter") {
|
target="_blank"
|
||||||
this.props.onClose();
|
rel="noreferrer"
|
||||||
}
|
download={image.src}
|
||||||
};
|
>
|
||||||
|
⬇ Download
|
||||||
private _onEscape = (e: React.KeyboardEvent) => {
|
</a>
|
||||||
if (e.key === "Escape") {
|
<span
|
||||||
this.props.onClose();
|
className="BigPicture-footerLink"
|
||||||
}
|
role="button"
|
||||||
};
|
onClick={onClose}
|
||||||
|
onKeyPress={(e) => {
|
||||||
private _onTouchStart = (e: React.TouchEvent) => {
|
if (e.key === "Enter") {
|
||||||
const touch = e.touches[0];
|
onClose();
|
||||||
this.setState({ touchStart: { x: touch.screenX, y: touch.screenY } });
|
}
|
||||||
};
|
}}
|
||||||
|
tabIndex={0}
|
||||||
private _onTouchEnd = (e: React.TouchEvent) => {
|
>
|
||||||
const touch = e.changedTouches[0];
|
Close
|
||||||
const touchStart = this.state.touchStart as TouchStart;
|
</span>
|
||||||
|
</div>
|
||||||
const dx = touch.screenX - touchStart.x;
|
</div>
|
||||||
|
);
|
||||||
if (Math.abs(dx) / window.innerWidth > 0.05) {
|
};
|
||||||
if (dx < 0) {
|
|
||||||
this.props.showNext();
|
|
||||||
} else {
|
|
||||||
this.props.showPrevious();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ touchStart: null });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -11,7 +10,7 @@ export interface Props {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROW_HEIGHT = 250;
|
export const ROW_HEIGHT = 350;
|
||||||
export const MOBILE_ROW_HEIGHT = 150;
|
export const MOBILE_ROW_HEIGHT = 150;
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
@@ -19,72 +18,127 @@ interface Row {
|
|||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Grid extends React.PureComponent<Props, {}> {
|
interface BadList {
|
||||||
static displayName: string = "Grid";
|
splits: number[];
|
||||||
|
badness: number;
|
||||||
|
}
|
||||||
|
|
||||||
private gridHeight = 0;
|
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;
|
||||||
|
|
||||||
render() {
|
return (rowHeight - height) * (rowHeight - height);
|
||||||
this.gridHeight = 0;
|
};
|
||||||
|
|
||||||
let row: Model.Image[] = [];
|
export const Grid: React.FC<Props> = ({
|
||||||
const rows: Row[] = [];
|
images,
|
||||||
let rowWidth = 0;
|
onImageSelected,
|
||||||
|
pageBottom,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}) => {
|
||||||
|
const rowsMemo = React.useRef<Map<number, Map<number, BadList>>>(new Map());
|
||||||
|
const targetRowHeight = width > 900 ? ROW_HEIGHT : MOBILE_ROW_HEIGHT;
|
||||||
|
|
||||||
this.props.images.forEach((image) => {
|
React.useEffect(() => {
|
||||||
const newWidth = rowWidth + image.width / image.height;
|
// The memoized rows depend on the image list; clear when the set changes
|
||||||
const height = this.props.width / newWidth;
|
rowsMemo.current.clear();
|
||||||
|
}, [images]);
|
||||||
|
|
||||||
if (height < this._rowHeight()) {
|
const rowsFor = (idx: number): BadList => {
|
||||||
rows.push({
|
const memo = rowsMemo.current.get(width) ?? new Map();
|
||||||
images: row,
|
const maybeMemo = memo.get(idx);
|
||||||
width: rowWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
row = [];
|
if (maybeMemo) {
|
||||||
rowWidth = image.width / image.height;
|
return maybeMemo;
|
||||||
} else {
|
}
|
||||||
rowWidth = newWidth;
|
|
||||||
|
if (idx === images.length) {
|
||||||
|
return {
|
||||||
|
splits: [],
|
||||||
|
badness: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (idx === images.length - 1) {
|
||||||
|
const img = images[idx];
|
||||||
|
const h = (img.height * width) / img.width;
|
||||||
|
return {
|
||||||
|
splits: [],
|
||||||
|
badness: (targetRowHeight - h) * (targetRowHeight - h),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let leastBad = 1e50;
|
||||||
|
let bestSplits: number[] = [];
|
||||||
|
|
||||||
|
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];
|
||||||
}
|
}
|
||||||
row.push(image);
|
}
|
||||||
});
|
|
||||||
rows.push({
|
|
||||||
images: row,
|
|
||||||
width: rowWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const images = rows.map((row) => {
|
const badList = {
|
||||||
const height = Math.min(this.props.height, this.props.width / row.width);
|
splits: bestSplits,
|
||||||
|
badness: leastBad,
|
||||||
|
};
|
||||||
|
|
||||||
const pics = row.images.map((image) => {
|
memo.set(idx, badList);
|
||||||
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;
|
if (!rowsMemo.current.has(width)) {
|
||||||
|
rowsMemo.current.set(width, memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return badList;
|
||||||
|
};
|
||||||
|
|
||||||
|
let gridHeight = 0;
|
||||||
|
|
||||||
|
const badList = rowsFor(0);
|
||||||
|
let lastBreak = 0;
|
||||||
|
const rows: Row[] = badList.splits.map((split) => {
|
||||||
|
const slice = images.slice(lastBreak, split);
|
||||||
|
lastBreak = split;
|
||||||
|
|
||||||
|
return {
|
||||||
|
images: slice,
|
||||||
|
width: slice.reduce((acc, img) => acc + img.width / img.height, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<Picture
|
||||||
className="Grid-row"
|
image={image}
|
||||||
style={{ height: height + "px" }}
|
onClick={() => onImageSelected(image)}
|
||||||
key={row.images.map((image) => image.src).join(",")}
|
key={image.src}
|
||||||
>
|
height={rowHeight}
|
||||||
{pics}
|
width={scaledWidth}
|
||||||
</div>
|
defer={defer}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return <div className="Grid">{images}</div>;
|
gridHeight += rowHeight;
|
||||||
}
|
|
||||||
|
|
||||||
private _rowHeight = (): number =>
|
return (
|
||||||
this.props.width > 900 ? ROW_HEIGHT : MOBILE_ROW_HEIGHT;
|
<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 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.RefObject<HTMLDivElement> = React.createRef();
|
onShowHome,
|
||||||
|
pageBottom,
|
||||||
render() {
|
width,
|
||||||
return (
|
height,
|
||||||
<div className="ImageSet" ref={this.divRef}>
|
}) => {
|
||||||
<h2>
|
return (
|
||||||
<span className="ImageSet-location">
|
<div className="ImageSet">
|
||||||
{this.props.imageSet.location}
|
<h2>
|
||||||
</span>
|
<span className="ImageSet-location">{imageSet.location}</span>
|
||||||
<span className="ImageSet-description">
|
<span className="ImageSet-description">{imageSet.description}</span>
|
||||||
{this.props.imageSet.description}
|
</h2>
|
||||||
</span>
|
<Grid
|
||||||
</h2>
|
images={imageSet.images}
|
||||||
<Grid
|
onImageSelected={onImageSelected}
|
||||||
images={this.props.imageSet.images}
|
pageBottom={pageBottom}
|
||||||
onImageSelected={this.props.onImageSelected}
|
width={width}
|
||||||
pageBottom={this.props.pageBottom}
|
height={height}
|
||||||
width={this.props.width}
|
/>
|
||||||
height={this.props.height}
|
<div className="ImageSet-navigation">
|
||||||
/>
|
<a
|
||||||
<div className="ImageSet-navigation">
|
href="#"
|
||||||
<a href="#" onClick={this.props.onShowHome}>
|
onClick={(e) => {
|
||||||
Back
|
e.preventDefault();
|
||||||
</a>
|
onShowHome();
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
componentDidMount() {
|
|
||||||
this._setGridHeight();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
this._setGridHeight();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _setGridHeight = () => {
|
|
||||||
if (this.divRef.current) {
|
|
||||||
this.props.setGridHeight(this.divRef.current.clientHeight);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,73 +9,46 @@ export interface Props {
|
|||||||
defer?: boolean;
|
defer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
|
||||||
isMounted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SrcSetInfo {
|
interface SrcSetInfo {
|
||||||
jpeg: string;
|
jpeg: string;
|
||||||
webp: string;
|
webp: string;
|
||||||
|
avif: string;
|
||||||
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.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[] = [];
|
||||||
let bestSize = 800;
|
let bestSize = 800;
|
||||||
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");
|
||||||
jpegSrcSet.push(`${jpeg} ${scale}x`);
|
jpegSrcSet.push(`${jpeg} ${scale}x`);
|
||||||
webpSrcSet.push(`${webp} ${scale}x`);
|
webpSrcSet.push(`${webp} ${scale}x`);
|
||||||
|
avifSrcSet.push(`${avif} ${scale}x`);
|
||||||
if (scale < bestScale) {
|
if (scale < bestScale) {
|
||||||
bestSize = size;
|
bestSize = size;
|
||||||
bestScale = scale;
|
bestScale = scale;
|
||||||
@@ -87,7 +59,28 @@ export class Picture extends React.PureComponent<Props, State> {
|
|||||||
return {
|
return {
|
||||||
jpeg: jpegSrcSet.join(","),
|
jpeg: jpegSrcSet.join(","),
|
||||||
webp: webpSrcSet.join(","),
|
webp: webpSrcSet.join(","),
|
||||||
bestSrc: `img/${bestSize}/${this.props.image.src}`,
|
avif: avifSrcSet.join(","),
|
||||||
|
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,224 +1,271 @@
|
|||||||
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;
|
const widths = [
|
||||||
selectedImage?: Model.Image | null;
|
window.innerWidth,
|
||||||
selectedSet?: Model.ImageSet | null;
|
window.outerWidth,
|
||||||
gridHeights: number[];
|
document.documentElement?.clientWidth,
|
||||||
pageBottom: number;
|
document.body?.clientWidth,
|
||||||
width: number;
|
].filter((w): w is number => typeof w === "number" && w > 0 && isFinite(w));
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Root extends React.PureComponent<Props, State> {
|
return widths.length > 0 ? Math.max(...widths) : 0;
|
||||||
static displayName = "Root";
|
};
|
||||||
|
|
||||||
// innerWidth gets messed up when rotating phones from landscape -> portrait,
|
const viewHeight = (): number => {
|
||||||
// and chrome seems to not report innerWidth correctly when scrollbars are present
|
const heights = [
|
||||||
private _viewWidth = (): number => {
|
window.innerHeight,
|
||||||
return Math.min(
|
window.outerHeight,
|
||||||
window.innerWidth,
|
document.documentElement?.clientHeight,
|
||||||
window.outerWidth || Infinity,
|
document.body?.clientHeight,
|
||||||
document.body.clientWidth
|
].filter((h): h is number => typeof h === "number" && h > 0 && isFinite(h));
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private _viewHeight = (): number => {
|
return heights.length > 0 ? Math.max(...heights) : 0;
|
||||||
return Math.min(
|
};
|
||||||
window.outerHeight || Infinity,
|
|
||||||
document.body.clientHeight || Infinity
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
state: State = {
|
const formatHash = (set: Model.ImageSet) =>
|
||||||
gridHeights: [],
|
set.location.replace(/[^a-zA-Z0-9-_]/g, "-") +
|
||||||
pageBottom: this._viewHeight() + window.pageYOffset,
|
"-" +
|
||||||
width: this._viewWidth(),
|
set.description.replace(/[^a-zA-Z0-9-_]/g, "-");
|
||||||
height: this._viewHeight(),
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateView = React.useCallback(() => {
|
||||||
|
const height = viewHeight();
|
||||||
|
setDimensions({
|
||||||
|
pageBottom: height + window.pageYOffset,
|
||||||
|
width: viewWidth(),
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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
|
window
|
||||||
.fetch(Model.dataUrl)
|
.fetch(Model.dataUrl)
|
||||||
.then((data) => data.json())
|
.then((response) => response.json())
|
||||||
.then((json) => this.setState({ data: json }))
|
.then((json) => {
|
||||||
.then(this._loadHash)
|
if (isMounted) {
|
||||||
.then(this._onViewChange)
|
setData(json);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch((e) => console.error("Error fetching data", e));
|
.catch((e) => console.error("Error fetching data", e));
|
||||||
|
|
||||||
window.onresize = this._onViewChange;
|
return () => {
|
||||||
window.onscroll = this._onViewChange;
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
try {
|
React.useEffect(() => {
|
||||||
screen.orientation.onchange = this._onViewChange;
|
if (data) {
|
||||||
} catch (e) {}
|
loadHash();
|
||||||
|
}
|
||||||
|
}, [data, loadHash]);
|
||||||
|
|
||||||
try {
|
React.useEffect(() => {
|
||||||
window.onorientationchange = this._onViewChange;
|
const handlePopState = () => loadHash();
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
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) {
|
const orientation = (screen as any).orientation;
|
||||||
return (
|
if (orientation?.addEventListener) {
|
||||||
<ImageSet
|
orientation.addEventListener("change", updateView);
|
||||||
key={set.location + set.description}
|
}
|
||||||
imageSet={set}
|
|
||||||
pageBottom={this.state.pageBottom}
|
updateView();
|
||||||
setGridHeight={this._setGridHeight(0)}
|
|
||||||
onImageSelected={this._onImageSelected}
|
return () => {
|
||||||
onShowHome={this._onHomeSelected}
|
window.removeEventListener("resize", updateView);
|
||||||
width={this.state.width}
|
window.removeEventListener("scroll", updateView);
|
||||||
height={this.state.height}
|
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 neighbors = React.useMemo(() => {
|
||||||
|
if (!selectedSet || !selectedImage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const images = selectedSet.images;
|
||||||
|
const current = images.indexOf(selectedImage);
|
||||||
|
const previousIdx = current - 1 < 0 ? images.length - 1 : current - 1;
|
||||||
|
const nextIdx = current + 1 >= images.length ? 0 : current + 1;
|
||||||
|
return {
|
||||||
|
previous: images[previousIdx],
|
||||||
|
next: images[nextIdx],
|
||||||
|
};
|
||||||
|
}, [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 (
|
return (
|
||||||
<div className="Root-setCovers">
|
<div className="Root-setCovers">
|
||||||
{sets.map((set) => (
|
{data.sets.map((set) => (
|
||||||
<SetCover
|
<SetCover
|
||||||
key={set.location + set.description}
|
key={set.location + set.description}
|
||||||
imageSet={set}
|
imageSet={set}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
this._onSetSelected(set);
|
onSetSelected(set);
|
||||||
scrollTo(0, 0);
|
scrollTo(0, 0);
|
||||||
}}
|
}}
|
||||||
width={Math.min(this.state.width, 400)}
|
width={Math.min(dimensions.width, 400)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 = () => {
|
return (
|
||||||
this.setState({
|
<div className="Root">
|
||||||
pageBottom: this._viewHeight() + window.pageYOffset,
|
{selectedImage ? (
|
||||||
width: this._viewWidth(),
|
<BigPicture
|
||||||
height: this._viewHeight(),
|
image={selectedImage}
|
||||||
});
|
previousImage={neighbors?.previous ?? selectedImage}
|
||||||
};
|
nextImage={neighbors?.next ?? selectedImage}
|
||||||
|
onClose={showGrid}
|
||||||
private _onImageSelected = (img: Model.Image) => {
|
showNext={showNextBigPicture}
|
||||||
this.setState({ selectedImage: img });
|
showPrevious={showPreviousBigPicture}
|
||||||
window.history.pushState(null, "", `#${img.src}`);
|
width={dimensions.width}
|
||||||
};
|
/>
|
||||||
|
) : null}
|
||||||
private _onSetSelected = (set: Model.ImageSet) => {
|
<h1 onClick={onHomeSelected}>Aaron's Ski Pictures</h1>
|
||||||
this.setState({ selectedSet: set });
|
{renderSets()}
|
||||||
document.title =
|
</div>
|
||||||
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 });
|
|
||||||
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 };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private _getPreviousGridHeights = (grid: number): number =>
|
|
||||||
this.state.gridHeights.slice(0, grid).reduce((a, b) => a + b, 0);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
const isTall = coverImage.height > coverImage.width;
|
||||||
|
|
||||||
export class SetCover extends React.PureComponent<Props, State> {
|
const height = isTall
|
||||||
static displayName = "SetCover";
|
? width
|
||||||
|
: (coverImage.height / coverImage.width) * width;
|
||||||
|
|
||||||
render() {
|
const normalizedWidth = isTall
|
||||||
const image = this.props.imageSet.images[0];
|
? (coverImage.width / coverImage.height) * width
|
||||||
const isTall = image.height > image.width;
|
: width;
|
||||||
|
|
||||||
const height = isTall
|
return (
|
||||||
? this.props.width
|
<div className="SetCover" onClick={onClick}>
|
||||||
: (image.height / image.width) * this.props.width;
|
<Picture
|
||||||
|
image={coverImage}
|
||||||
const width = isTall
|
onClick={() => {}}
|
||||||
? (image.width / image.height) * this.props.width
|
height={height}
|
||||||
: this.props.width;
|
width={normalizedWidth}
|
||||||
|
/>
|
||||||
return (
|
<h2>
|
||||||
<div className="SetCover" onClick={this.props.onClick}>
|
<span className="SetCover-location">{imageSet.location}</span>
|
||||||
<Picture
|
<span className="SetCover-description">{imageSet.description}</span>
|
||||||
image={image}
|
</h2>
|
||||||
onClick={() => {}}
|
</div>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Root } from "./components/root";
|
import { Root } from "components/root";
|
||||||
|
|
||||||
import * as React from "react";
|
import { createRoot } from "react-dom/client";
|
||||||
import * as ReactDOM from "react-dom";
|
|
||||||
|
|
||||||
const body = document.getElementById("mount");
|
const body = document.getElementById("mount") as HTMLElement;
|
||||||
|
const root = createRoot(body);
|
||||||
ReactDOM.render(<Root />, body);
|
root.render(<Root />);
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist/",
|
"outDir": "./dist/",
|
||||||
"baseUrl": ".",
|
"baseUrl": "./src",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"target": "es6",
|
"strict": true,
|
||||||
"jsx": "react",
|
"target": "ES2020",
|
||||||
"module": "es6"
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"./src/**/*"
|
"./src/**/*"
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ module.exports = (env) => {
|
|||||||
entry: "./src/index.tsx",
|
entry: "./src/index.tsx",
|
||||||
output: {
|
output: {
|
||||||
filename: "bundle.js",
|
filename: "bundle.js",
|
||||||
path: path.join(__dirname, "dist")
|
path: path.join(__dirname, "dist"),
|
||||||
|
clean: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
mode: mode,
|
mode: mode,
|
||||||
|
target: "web",
|
||||||
|
|
||||||
// Enable sourcemaps for debugging webpack's output.
|
// Enable sourcemaps for debugging webpack's output.
|
||||||
devtool: "source-map",
|
devtool: "source-map",
|
||||||
performance: {
|
|
||||||
hints: false
|
|
||||||
},
|
|
||||||
|
|
||||||
devServer: {
|
devServer: {
|
||||||
static: {
|
static: {
|
||||||
@@ -27,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: {
|
||||||
@@ -37,5 +37,9 @@ module.exports = (env) => {
|
|||||||
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" }
|
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
experiments: {
|
||||||
|
topLevelAwait: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user