Migrate to Vite / Vite+
Modernize building and such
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,8 +2,6 @@
|
||||
*.swo
|
||||
dist/
|
||||
img/*
|
||||
!img/convert.sh
|
||||
!img/data.json
|
||||
node_modules
|
||||
.DS_Store
|
||||
venv/
|
||||
.idea/
|
||||
|
||||
1
.vite-hooks/pre-commit
Executable file
1
.vite-hooks/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
vp staged
|
||||
88
AGENTS.md
Normal file
88
AGENTS.md
Normal file
@@ -0,0 +1,88 @@
|
||||
<!--VITE PLUS START-->
|
||||
|
||||
# Using Vite+, the Unified Toolchain for the Web
|
||||
|
||||
This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`.
|
||||
|
||||
## Vite+ Workflow
|
||||
|
||||
`vp` is a global binary that handles the full development lifecycle. Run `vp help` to print a list of commands and `vp <command> --help` for information about a specific command.
|
||||
|
||||
### Start
|
||||
|
||||
- create - Create a new project from a template
|
||||
- migrate - Migrate an existing project to Vite+
|
||||
- config - Configure hooks and agent integration
|
||||
- staged - Run linters on staged files
|
||||
- install (`i`) - Install dependencies
|
||||
- env - Manage Node.js versions
|
||||
|
||||
### Develop
|
||||
|
||||
- dev - Run the development server
|
||||
- check - Run format, lint, and TypeScript type checks
|
||||
- lint - Lint code
|
||||
- fmt - Format code
|
||||
- test - Run tests
|
||||
|
||||
### Execute
|
||||
|
||||
- run - Run monorepo tasks
|
||||
- exec - Execute a command from local `node_modules/.bin`
|
||||
- dlx - Execute a package binary without installing it as a dependency
|
||||
- cache - Manage the task cache
|
||||
|
||||
### Build
|
||||
|
||||
- build - Build for production
|
||||
- pack - Build libraries
|
||||
- preview - Preview production build
|
||||
|
||||
### Manage Dependencies
|
||||
|
||||
Vite+ automatically detects and wraps the underlying package manager such as pnpm, npm, or Yarn through the `packageManager` field in `package.json` or package manager-specific lockfiles.
|
||||
|
||||
- add - Add packages to dependencies
|
||||
- remove (`rm`, `un`, `uninstall`) - Remove packages from dependencies
|
||||
- update (`up`) - Update packages to latest versions
|
||||
- dedupe - Deduplicate dependencies
|
||||
- outdated - Check for outdated packages
|
||||
- list (`ls`) - List installed packages
|
||||
- why (`explain`) - Show why a package is installed
|
||||
- info (`view`, `show`) - View package information from the registry
|
||||
- link (`ln`) / unlink - Manage local package links
|
||||
- pm - Forward a command to the package manager
|
||||
|
||||
### Maintain
|
||||
|
||||
- upgrade - Update `vp` itself to the latest version
|
||||
|
||||
These commands map to their corresponding tools. For example, `vp dev --port 3000` runs Vite's dev server and works the same as Vite. `vp test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vp --version`. This is useful when researching documentation, features, and bugs.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Using the package manager directly:** Do not use pnpm, npm, or Yarn directly. Vite+ can handle all package manager operations.
|
||||
- **Always use Vite commands to run tools:** Don't attempt to run `vp vitest` or `vp oxlint`. They do not exist. Use `vp test` and `vp lint` instead.
|
||||
- **Running scripts:** Vite+ built-in commands (`vp dev`, `vp build`, `vp test`, etc.) always run the Vite+ built-in tool, not any `package.json` script of the same name. To run a custom script that shares a name with a built-in command, use `vp run <script>`. For example, if you have a custom `dev` script that runs multiple services concurrently, run it with `vp run dev`, not `vp dev` (which always starts Vite's dev server).
|
||||
- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly:** Vite+ wraps these tools. They must not be installed directly. You cannot upgrade these tools by installing their latest versions. Always use Vite+ commands.
|
||||
- **Use Vite+ wrappers for one-off binaries:** Use `vp dlx` instead of package-manager-specific `dlx`/`npx` commands.
|
||||
- **Import JavaScript modules from `vite-plus`:** Instead of importing from `vite` or `vitest`, all modules should be imported from the project's `vite-plus` dependency. For example, `import { defineConfig } from 'vite-plus';` or `import { expect, test, vi } from 'vite-plus/test';`. You must not install `vitest` to import test utilities.
|
||||
- **Type-Aware Linting:** There is no need to install `oxlint-tsgolint`, `vp lint --type-aware` works out of the box.
|
||||
|
||||
## CI Integration
|
||||
|
||||
For GitHub Actions, consider using [`voidzero-dev/setup-vp`](https://github.com/voidzero-dev/setup-vp) to replace separate `actions/setup-node`, package-manager setup, cache, and install steps with a single action.
|
||||
|
||||
```yaml
|
||||
- uses: voidzero-dev/setup-vp@v1
|
||||
with:
|
||||
cache: true
|
||||
- run: vp check
|
||||
- run: vp test
|
||||
```
|
||||
|
||||
## Review Checklist for Agents
|
||||
|
||||
- [ ] Run `vp install` after pulling remote changes and before getting started.
|
||||
- [ ] Run `vp check` and `vp test` to validate changes.
|
||||
<!--VITE PLUS END-->
|
||||
22
README.md
22
README.md
@@ -1,4 +1,24 @@
|
||||
# Ski
|
||||
|
||||
## [ski.aarongutierrez.com](https://ski.aarongutierrez.com)
|
||||
|
||||
A portfolio site for my ski photography. Built with typescript, react, and <3
|
||||
A portfolio site for my ski photography. Built with TypeScript, React, Vite, and <3
|
||||
|
||||
## Commands
|
||||
|
||||
- `vp dev` starts the Vite+ dev server.
|
||||
- `vp check` runs formatting, linting, and type checks through Vite+.
|
||||
- `vp fmt` formats supported source files through Oxfmt.
|
||||
- `vp lint` runs Oxlint with type-aware checks.
|
||||
- `vp build` creates a production build in `dist/`.
|
||||
- `npm run images` regenerates hashed originals and resized image variants from `img/original/`, then prepends newly discovered images to `img/data.json` under a placeholder set you can fill in.
|
||||
- `npm run publish` builds the app, uploads `dist/` and `img/` to S3, ensures CloudFront serves `index.html`, and invalidates `/` plus `/index.html`.
|
||||
|
||||
React Compiler is enabled through the Vite React plugin, so local dev and production builds both run with the compiler transform.
|
||||
|
||||
## Deploy Prerequisites
|
||||
|
||||
- The publish script expects AWS credentials under the `push` profile.
|
||||
- Publishing uses the TypeScript script at `scripts/publish.ts`.
|
||||
- `npm run publish` executes that script with Node's TypeScript support.
|
||||
- `npm run images` expects `magick` to be installed and available on your shell path.
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
shopt -s nullglob
|
||||
|
||||
function make_jpg {
|
||||
if [ ! -f $2/$1 ]; then
|
||||
echo "Converting $2/$1"
|
||||
magick $1 -resize $2x$2 -quality 30 $2/$1
|
||||
fi
|
||||
}
|
||||
|
||||
function make_webp {
|
||||
NAME="$(basename $1 .jpg).webp"
|
||||
if [ ! -f $2/$NAME ]; then
|
||||
echo "Converting $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
|
||||
}
|
||||
|
||||
for f in original/*.jpg; do
|
||||
SUM=$(md5 < $f)
|
||||
cp $f ${SUM}.jpg;
|
||||
magick identify -format "{ \"src\": \"%f\", \"width\": %w, \"height\": %h },\n" ${SUM}.jpg >> new_data.json
|
||||
done
|
||||
|
||||
for img in *.jpg; do
|
||||
make_jpg $img 2400 &
|
||||
make_jpg $img 1600 &
|
||||
make_jpg $img 1200 &
|
||||
make_jpg $img 800 &
|
||||
make_jpg $img 600 &
|
||||
make_jpg $img 400 &
|
||||
make_jpg $img 200 &
|
||||
|
||||
make_webp $img 2400 50 &
|
||||
make_webp $img 1600 50 &
|
||||
make_webp $img 1200 50 &
|
||||
make_webp $img 800 40 &
|
||||
make_webp $img 600 40 &
|
||||
make_webp $img 400 40 &
|
||||
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
|
||||
2529
img/data.json
Normal file
2529
img/data.json
Normal file
File diff suppressed because it is too large
Load Diff
20
index.html
20
index.html
@@ -1,13 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Skiing - Aaron Gutierrez</title>
|
||||
<link rel="stylesheet" href="site.css">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta property="og:title" content="Aaron's Ski Pictures" />
|
||||
<meta property="og:description" content="Mostly ski pictures, mostly in the backcountry." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://ski.aarongutierrez.com" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://ski.aarongutierrez.com/img/1600/112873ef54777a15bd9e5cd03362d5a5.jpg"
|
||||
/>
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content="1600" />
|
||||
<meta property="og:image:height" content="1067" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="mount"><noscript>I don't like javascript, either.</noscript></div>
|
||||
<script type="text/javascript" src="bundle.js"></script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
8079
package-lock.json
generated
8079
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
@@ -2,31 +2,46 @@
|
||||
"name": "ski",
|
||||
"version": "1.0.0",
|
||||
"description": "Ski Photos",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"prettier": "prettier --write $(git ls-files \"*.ts*\")",
|
||||
"dev": "webpack-dev-server --env dev"
|
||||
},
|
||||
"license": "ISC",
|
||||
"author": "Aaron Gutierrez",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@git.frat.tech:aaron/ski.git"
|
||||
},
|
||||
"author": "Aaron Gutierrez",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "vp check",
|
||||
"dev": "vp dev",
|
||||
"build": "vp build",
|
||||
"fmt": "vp fmt",
|
||||
"images": "node --experimental-strip-types scripts/convert.ts",
|
||||
"lint": "vp lint",
|
||||
"preview": "vp preview",
|
||||
"publish": "node --experimental-strip-types scripts/publish.ts",
|
||||
"prepare": "vp config"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"@aws-sdk/client-cloudfront": "^3.888.0",
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
"@aws-sdk/credential-providers": "^3.888.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"esbuild": "^0.25.4",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"ts-loader": "^9.5.2",
|
||||
"typescript": "^5.8.3",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.1"
|
||||
}
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||
"vite-plus": "latest"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
||||
},
|
||||
"packageManager": "npm@11.12.1"
|
||||
}
|
||||
|
||||
174
pub.py
174
pub.py
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
from functools import cache
|
||||
|
||||
import boto3
|
||||
|
||||
|
||||
BUCKET='ski.aarongutierrez.com'
|
||||
DISTRIBUTION='ELFNI2LSHJUNK'
|
||||
|
||||
|
||||
session = boto3.Session(profile_name='push')
|
||||
s3 = session.client('s3')
|
||||
cloudfront = session.client('cloudfront')
|
||||
|
||||
|
||||
TYPE_MAP = {
|
||||
'avif': 'image/avif',
|
||||
'css': 'text/css',
|
||||
'gif': 'image/gif',
|
||||
'html': 'text/html; charset=utf8',
|
||||
'jpg': 'image/jpeg',
|
||||
'js': 'application/javascript',
|
||||
'json': 'application/json',
|
||||
'png': 'image/png',
|
||||
'webp': 'image/webp',
|
||||
}
|
||||
|
||||
|
||||
CSS_FILE = 'site.css'
|
||||
BUNDLE_FILE = 'dist/bundle.js'
|
||||
|
||||
|
||||
def cache_length(ext):
|
||||
if ext == 'html':
|
||||
return '86400'
|
||||
elif ext == 'json':
|
||||
return '3600'
|
||||
else:
|
||||
return '31536000'
|
||||
|
||||
|
||||
@cache
|
||||
def current_keys():
|
||||
print('Fetching existing keys in {}'.format(BUCKET))
|
||||
existing = s3.list_objects_v2(Bucket=BUCKET)
|
||||
keys = set([content['Key'] for content in existing['Contents']])
|
||||
while existing['IsTruncated']:
|
||||
existing = s3.list_objects_v2(Bucket=BUCKET, ContinuationToken=existing['NextContinuationToken'])
|
||||
keys = keys.union(set([content['Key'] for content in existing['Contents']]))
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def upload_file(filename, overwrite=True):
|
||||
ext = filename.split('.')[-1]
|
||||
|
||||
if not overwrite and filename in current_keys():
|
||||
print('Skipping existing key {}'.format(filename))
|
||||
return
|
||||
|
||||
print('Uploading {} to {}/{}'.format(filename, BUCKET, filename))
|
||||
s3.upload_file(filename, BUCKET, filename, ExtraArgs={
|
||||
'ACL': 'public-read',
|
||||
'ContentType': TYPE_MAP[ext],
|
||||
'CacheControl': 'public, max-age={}'.format(cache_length(ext))
|
||||
})
|
||||
print('\tDone.')
|
||||
|
||||
|
||||
def set_default_object(key):
|
||||
config = cloudfront.get_distribution_config(Id=DISTRIBUTION)
|
||||
|
||||
etag = config['ETag']
|
||||
distributionConfig = config['DistributionConfig']
|
||||
|
||||
distributionConfig['DefaultRootObject'] = key
|
||||
|
||||
print('Setting distribution {} to have root object {}'.format(DISTRIBUTION, key))
|
||||
cloudfront.update_distribution(Id=DISTRIBUTION,
|
||||
IfMatch=etag,
|
||||
DistributionConfig=distributionConfig)
|
||||
print('\tDone.')
|
||||
|
||||
|
||||
def filter_filenames(filenames, ext):
|
||||
for f in filenames:
|
||||
if (isinstance(ext, (list,)) and (f.split('.')[-1] in ext) \
|
||||
or f.split('.')[-1] == ext):
|
||||
yield f
|
||||
|
||||
|
||||
def file_sha(filename):
|
||||
output = subprocess.check_output(['shasum', '-a', '256', filename])
|
||||
return output.decode().split(' ')[0]
|
||||
|
||||
|
||||
def upload_root():
|
||||
subprocess.check_call(['webpack'])
|
||||
|
||||
css_sha = file_sha(CSS_FILE)
|
||||
css_filename = '{}.css'.format(css_sha)
|
||||
bundle_sha = file_sha(BUNDLE_FILE)
|
||||
bundle_filename = '{}.js'.format(bundle_sha)
|
||||
|
||||
subprocess.check_call(['cp', CSS_FILE, css_filename])
|
||||
subprocess.check_call(['cp', BUNDLE_FILE, bundle_filename])
|
||||
|
||||
with open('template.html', 'r') as f:
|
||||
template = f.read()
|
||||
|
||||
index = template.format(css_filename, bundle_filename)
|
||||
h = hashlib.sha256()
|
||||
h.update(bytes(index, 'utf-8'))
|
||||
|
||||
index_sha = h.hexdigest()
|
||||
index_filename = '{}.html'.format(index_sha)
|
||||
|
||||
with open(index_filename, 'w') as f:
|
||||
f.write(index)
|
||||
|
||||
upload_file(css_filename)
|
||||
upload_file(bundle_filename)
|
||||
upload_file(index_filename)
|
||||
|
||||
subprocess.check_call(['rm', '-f', css_filename])
|
||||
subprocess.check_call(['rm', '-f', bundle_filename])
|
||||
subprocess.check_call(['rm', '-f', index_filename])
|
||||
|
||||
set_default_object(index_filename)
|
||||
|
||||
|
||||
def upload_original():
|
||||
files = filter_filenames(os.listdir('img'), ['jpg', 'webp', 'avif'])
|
||||
for f in files:
|
||||
upload_file('img/{}'.format(f), overwrite=False)
|
||||
|
||||
|
||||
def upload_thumbnail(size):
|
||||
files = filter_filenames(os.listdir('img/{}'.format(size)), ['jpg', 'webp', 'avif'])
|
||||
for f in files:
|
||||
upload_file('img/{0}/{1}'.format(size, f), overwrite=False)
|
||||
|
||||
|
||||
def upload_images():
|
||||
upload_original()
|
||||
upload_thumbnail(200)
|
||||
upload_thumbnail(400)
|
||||
upload_thumbnail(600)
|
||||
upload_thumbnail(800)
|
||||
upload_thumbnail(1200)
|
||||
upload_thumbnail(1600)
|
||||
upload_thumbnail(2400)
|
||||
upload_file('img/data.json')
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description='Publish ski.aarongutierrez.com')
|
||||
|
||||
pub_help = 'What to publish'
|
||||
pub_choices = ['all', 'root', 'img']
|
||||
parser.add_argument('pub', choices=pub_choices, help=pub_help)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.pub == 'root' or args.pub == 'all':
|
||||
upload_root()
|
||||
if args.pub == 'img' or args.pub == 'all':
|
||||
upload_images()
|
||||
262
scripts/convert.ts
Normal file
262
scripts/convert.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { cp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const IMAGE_ROOT = "img";
|
||||
const ORIGINAL_DIR = path.join(IMAGE_ROOT, "original");
|
||||
const DATA_FILE = path.join(IMAGE_ROOT, "data.json");
|
||||
const JPG_QUALITY = 30;
|
||||
const JSON_WIDTH = 100;
|
||||
const PLACEHOLDER_LOCATION = "TODO location";
|
||||
const PLACEHOLDER_DESCRIPTION = "TODO description";
|
||||
|
||||
const IMAGE_SIZES = [2400, 1600, 1200, 800, 600, 400, 200] as const;
|
||||
const WEBP_QUALITIES = new Map<number, number>([
|
||||
[2400, 50],
|
||||
[1600, 50],
|
||||
[1200, 50],
|
||||
[800, 40],
|
||||
[600, 40],
|
||||
[400, 40],
|
||||
[200, 40],
|
||||
]);
|
||||
const AVIF_QUALITIES = new Map<number, number>([
|
||||
[2400, 70],
|
||||
[1600, 70],
|
||||
[1200, 70],
|
||||
[800, 60],
|
||||
[600, 60],
|
||||
[400, 50],
|
||||
[200, 50],
|
||||
]);
|
||||
|
||||
interface ManifestImage {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ImageSet {
|
||||
location: string;
|
||||
description: string;
|
||||
images: ManifestImage[];
|
||||
}
|
||||
|
||||
interface GalleryData {
|
||||
sets: ImageSet[];
|
||||
}
|
||||
|
||||
const runCommand = async (command: string, args: string[]): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}\n${stderr}`.trim()));
|
||||
});
|
||||
});
|
||||
|
||||
const exists = async (target: string): Promise<boolean> => {
|
||||
try {
|
||||
await stat(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const identifyImage = async (filename: string): Promise<ManifestImage> => {
|
||||
const output = await runCommand("magick", ["identify", "-format", "%f,%w,%h", filename]);
|
||||
const [src, width, height] = output.trim().split(",");
|
||||
return {
|
||||
src,
|
||||
width: Number(width),
|
||||
height: Number(height),
|
||||
};
|
||||
};
|
||||
|
||||
const resizeImage = async (
|
||||
source: string,
|
||||
output: string,
|
||||
size: number,
|
||||
quality: number,
|
||||
): Promise<void> => {
|
||||
if (await exists(output)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
console.log(`Converting ${output}`);
|
||||
await runCommand("magick", [
|
||||
source,
|
||||
"-resize",
|
||||
`${size}x${size}`,
|
||||
"-quality",
|
||||
`${quality}`,
|
||||
output,
|
||||
]);
|
||||
};
|
||||
|
||||
const hashOriginal = async (source: string): Promise<string> => {
|
||||
const contents = await readFile(source);
|
||||
return createHash("md5").update(contents).digest("hex");
|
||||
};
|
||||
|
||||
const sourceFiles = async (): Promise<string[]> => {
|
||||
const entries = await readdir(ORIGINAL_DIR, { withFileTypes: true });
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".jpg"))
|
||||
.map((entry) => path.join(ORIGINAL_DIR, entry.name))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const processOriginal = async (source: string): Promise<ManifestImage> => {
|
||||
const hash = await hashOriginal(source);
|
||||
const hashedName = `${hash}.jpg`;
|
||||
const hashedPath = path.join(IMAGE_ROOT, hashedName);
|
||||
|
||||
if (!(await exists(hashedPath))) {
|
||||
console.log(`Copying ${hashedPath}`);
|
||||
await cp(source, hashedPath);
|
||||
}
|
||||
|
||||
const metadata = await identifyImage(hashedPath);
|
||||
|
||||
const conversions: Promise<void>[] = [];
|
||||
for (const size of IMAGE_SIZES) {
|
||||
conversions.push(
|
||||
resizeImage(hashedPath, path.join(IMAGE_ROOT, `${size}`, hashedName), size, JPG_QUALITY),
|
||||
);
|
||||
conversions.push(
|
||||
resizeImage(
|
||||
hashedPath,
|
||||
path.join(IMAGE_ROOT, `${size}`, hashedName.replace(/\.jpg$/i, ".webp")),
|
||||
size,
|
||||
WEBP_QUALITIES.get(size) ?? 40,
|
||||
),
|
||||
);
|
||||
conversions.push(
|
||||
resizeImage(
|
||||
hashedPath,
|
||||
path.join(IMAGE_ROOT, `${size}`, hashedName.replace(/\.jpg$/i, ".avif")),
|
||||
size,
|
||||
AVIF_QUALITIES.get(size) ?? 50,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(conversions);
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const readGalleryData = async (): Promise<GalleryData> => {
|
||||
const contents = await readFile(DATA_FILE, "utf8");
|
||||
return JSON.parse(contents) as GalleryData;
|
||||
};
|
||||
|
||||
const findNewImages = (existing: GalleryData, images: ManifestImage[]): ManifestImage[] => {
|
||||
const known = new Set(existing.sets.flatMap((set) => set.images.map((image) => image.src)));
|
||||
|
||||
return images.filter((image) => !known.has(image.src));
|
||||
};
|
||||
|
||||
const indent = (depth: number): string => " ".repeat(depth);
|
||||
|
||||
const formatImage = (image: ManifestImage, depth: number): string =>
|
||||
`${indent(depth)}{ "src": "${image.src}", "width": ${image.width}, "height": ${image.height} }`;
|
||||
|
||||
const formatSet = (set: ImageSet, depth: number): string => {
|
||||
const inline = `{ "location": ${JSON.stringify(set.location)}, "description": ${JSON.stringify(
|
||||
set.description,
|
||||
)}, "images": [] }`;
|
||||
if (set.images.length === 0 && indent(depth).length + inline.length <= JSON_WIDTH) {
|
||||
return `${indent(depth)}${inline}`;
|
||||
}
|
||||
|
||||
return [
|
||||
`${indent(depth)}{`,
|
||||
`${indent(depth + 1)}"location": ${JSON.stringify(set.location)},`,
|
||||
`${indent(depth + 1)}"description": ${JSON.stringify(set.description)},`,
|
||||
`${indent(depth + 1)}"images": [`,
|
||||
set.images
|
||||
.map((image, index) => {
|
||||
const suffix = index === set.images.length - 1 ? "" : ",";
|
||||
return `${formatImage(image, depth + 2)}${suffix}`;
|
||||
})
|
||||
.join("\n"),
|
||||
`${indent(depth + 1)}]`,
|
||||
`${indent(depth)}}`,
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const formatGalleryData = (data: GalleryData): string => {
|
||||
const sets = data.sets
|
||||
.map((set, index) => {
|
||||
const suffix = index === data.sets.length - 1 ? "" : ",";
|
||||
return `${formatSet(set, 2)}${suffix}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `{\n "sets": [\n${sets}\n ]\n}\n`;
|
||||
};
|
||||
|
||||
const prependPlaceholderSet = async (images: ManifestImage[]): Promise<void> => {
|
||||
const existing = await readGalleryData();
|
||||
const newImages = findNewImages(existing, images);
|
||||
|
||||
if (newImages.length === 0) {
|
||||
console.log("No new images to add to img/data.json");
|
||||
return;
|
||||
}
|
||||
|
||||
existing.sets.unshift({
|
||||
location: PLACEHOLDER_LOCATION,
|
||||
description: PLACEHOLDER_DESCRIPTION,
|
||||
images: newImages,
|
||||
});
|
||||
|
||||
await writeFile(DATA_FILE, formatGalleryData(existing), "utf8");
|
||||
console.log(`Prepended ${newImages.length} new images to ${DATA_FILE}`);
|
||||
};
|
||||
|
||||
export const convert = async (): Promise<void> => {
|
||||
const originals = await sourceFiles();
|
||||
const manifest: ManifestImage[] = [];
|
||||
|
||||
for (const original of originals) {
|
||||
manifest.push(await processOriginal(original));
|
||||
}
|
||||
|
||||
await prependPlaceholderSet(manifest);
|
||||
};
|
||||
|
||||
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||
|
||||
if (entrypoint === import.meta.url) {
|
||||
convert().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
246
scripts/publish.ts
Normal file
246
scripts/publish.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
CloudFrontClient,
|
||||
CreateInvalidationCommand,
|
||||
GetDistributionConfigCommand,
|
||||
UpdateDistributionCommand,
|
||||
} from "@aws-sdk/client-cloudfront";
|
||||
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { fromIni } from "@aws-sdk/credential-providers";
|
||||
import mime from "mime-types";
|
||||
|
||||
const BUCKET = "ski.aarongutierrez.com";
|
||||
const DISTRIBUTION = "ELFNI2LSHJUNK";
|
||||
const DIST_DIR = "dist";
|
||||
const IMAGE_DIR = "img";
|
||||
const AWS_PROFILE = "push";
|
||||
|
||||
const credentials = fromIni({ profile: AWS_PROFILE });
|
||||
|
||||
const s3 = new S3Client({ credentials });
|
||||
const cloudfront = new CloudFrontClient({ credentials });
|
||||
|
||||
let existingKeysPromise: Promise<Set<string>> | null = null;
|
||||
|
||||
const currentKeys = async (): Promise<Set<string>> => {
|
||||
if (existingKeysPromise) {
|
||||
return existingKeysPromise;
|
||||
}
|
||||
|
||||
existingKeysPromise = (async () => {
|
||||
console.log(`Fetching existing keys in ${BUCKET}`);
|
||||
const keys = new Set<string>();
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await s3.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: BUCKET,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const content of response.Contents ?? []) {
|
||||
if (content.Key) {
|
||||
keys.add(content.Key);
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
return keys;
|
||||
})();
|
||||
|
||||
return existingKeysPromise;
|
||||
};
|
||||
|
||||
const contentTypeFor = (filename: string): string => {
|
||||
const contentType = mime.lookup(filename);
|
||||
if (!contentType) {
|
||||
throw new Error(`Unknown content type for ${filename}`);
|
||||
}
|
||||
|
||||
if (contentType.startsWith("text/html")) {
|
||||
return "text/html; charset=utf-8";
|
||||
}
|
||||
|
||||
if (contentType.startsWith("text/plain")) {
|
||||
return "text/plain; charset=utf-8";
|
||||
}
|
||||
|
||||
return contentType;
|
||||
};
|
||||
|
||||
const cacheControlFor = (key: string): string => {
|
||||
if (key === "index.html") {
|
||||
return "no-cache, max-age=0, must-revalidate";
|
||||
}
|
||||
|
||||
if (key.startsWith("assets/")) {
|
||||
return "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
if (key.startsWith("img/")) {
|
||||
return "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
return "public, max-age=3600";
|
||||
};
|
||||
|
||||
const uploadFile = async (filename: string, key: string, overwrite: boolean): Promise<void> => {
|
||||
if (!overwrite) {
|
||||
const keys = await currentKeys();
|
||||
if (keys.has(key)) {
|
||||
console.log(`Skipping existing key ${key}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Uploading ${filename} to ${BUCKET}/${key}`);
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
ACL: "public-read",
|
||||
Body: createReadStream(filename),
|
||||
Bucket: BUCKET,
|
||||
CacheControl: cacheControlFor(key),
|
||||
ContentType: contentTypeFor(filename),
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
console.log("\tDone.");
|
||||
};
|
||||
|
||||
const walkFiles = async (root: string): Promise<string[]> => {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (entry.name === ".DS_Store") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(root, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkFiles(fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const uploadTree = async (root: string, keyPrefix: string, overwrite: boolean): Promise<void> => {
|
||||
const files = await walkFiles(root);
|
||||
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file).split(path.sep).join("/");
|
||||
if (root === IMAGE_DIR && relative === "data.json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = keyPrefix ? `${keyPrefix}/${relative}` : relative;
|
||||
await uploadFile(file, key, overwrite);
|
||||
}
|
||||
};
|
||||
|
||||
const runCommand = async (command: string, args: string[]): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
const buildApp = async (): Promise<void> => {
|
||||
await runCommand("vp", ["build"]);
|
||||
};
|
||||
|
||||
const ensureDefaultRoot = async (): Promise<void> => {
|
||||
const response = await cloudfront.send(new GetDistributionConfigCommand({ Id: DISTRIBUTION }));
|
||||
|
||||
if (!response.DistributionConfig || !response.ETag) {
|
||||
throw new Error("Missing CloudFront distribution config");
|
||||
}
|
||||
|
||||
if (response.DistributionConfig.DefaultRootObject === "index.html") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Setting default root object to index.html on ${DISTRIBUTION}`);
|
||||
await cloudfront.send(
|
||||
new UpdateDistributionCommand({
|
||||
DistributionConfig: {
|
||||
...response.DistributionConfig,
|
||||
DefaultRootObject: "index.html",
|
||||
},
|
||||
Id: DISTRIBUTION,
|
||||
IfMatch: response.ETag,
|
||||
}),
|
||||
);
|
||||
console.log("\tDone.");
|
||||
};
|
||||
|
||||
const invalidateRoot = async (): Promise<void> => {
|
||||
console.log(`Invalidating / and /index.html on ${DISTRIBUTION}`);
|
||||
await cloudfront.send(
|
||||
new CreateInvalidationCommand({
|
||||
DistributionId: DISTRIBUTION,
|
||||
InvalidationBatch: {
|
||||
CallerReference: `${Date.now()}`,
|
||||
Paths: {
|
||||
Items: ["/", "/index.html"],
|
||||
Quantity: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
console.log("\tDone.");
|
||||
};
|
||||
|
||||
const ensureDirectory = async (directory: string): Promise<void> => {
|
||||
const info = await stat(directory);
|
||||
if (!info.isDirectory()) {
|
||||
throw new Error(`${directory} is not a directory`);
|
||||
}
|
||||
};
|
||||
|
||||
export const publish = async (): Promise<void> => {
|
||||
await buildApp();
|
||||
await ensureDirectory(DIST_DIR);
|
||||
await ensureDirectory(IMAGE_DIR);
|
||||
await uploadTree(DIST_DIR, "", true);
|
||||
await uploadTree(IMAGE_DIR, "img", false);
|
||||
await ensureDefaultRoot();
|
||||
await invalidateRoot();
|
||||
};
|
||||
|
||||
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||
|
||||
if (entrypoint === import.meta.url) {
|
||||
publish().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
218
site.css
218
site.css
@@ -1,218 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
background-color: #fff;
|
||||
color: #335;
|
||||
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Helvetica,Arial,sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body.no-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #69c;
|
||||
cursor: pointer;
|
||||
font-size: 45px;
|
||||
font-weight: lighter;
|
||||
line-height: 60px;
|
||||
margin-bottom: 0;
|
||||
margin-left: 30px;
|
||||
margin-right: 30px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #666;
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
line-height: 30px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: 30px;
|
||||
margin-right: 30px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #69c;
|
||||
font-size: 18px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ImageSet h2 {
|
||||
border-bottom: 1px solid #eef;
|
||||
}
|
||||
|
||||
.Root-setCovers {
|
||||
display: grid;
|
||||
gap: 30px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.SetCover {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ImageSet-location,
|
||||
.ImageSet-description,
|
||||
.SetCover-location,
|
||||
.SetCover-description {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ImageSet-location:after,
|
||||
.SetCover-location:after {
|
||||
content: " · ";
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.ImageSet-navigation {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
|
||||
.Grid {
|
||||
margin-bottom: 45px;
|
||||
}
|
||||
|
||||
.Grid-row {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.Grid img {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.BigPicture {
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
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 {
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: ease-in-out;
|
||||
transition-property: height, width, opacity;
|
||||
}
|
||||
|
||||
.BigPicture-footer {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.BigPicture-footerLink {
|
||||
color: #69c;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 0 18px rgba(0, 0, 0, .7);
|
||||
}
|
||||
|
||||
.BigPicture-footerLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html,
|
||||
body {
|
||||
background-color: #111;
|
||||
color: #ccf;
|
||||
}
|
||||
|
||||
.ImageSet h2 {
|
||||
border-color: #335;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.Root-setCovers {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.Root-setCovers {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import { Picture } from "components/picture";
|
||||
import * as Model from "@/model";
|
||||
import { Picture } from "@/components/picture";
|
||||
|
||||
export interface Props {
|
||||
image: Model.Image;
|
||||
@@ -48,9 +48,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
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 lastTapRef = React.useRef<{ time: number; x: number; y: number } | null>(null);
|
||||
|
||||
const onEscape = React.useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -58,7 +56,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const goNext = React.useCallback(() => {
|
||||
@@ -103,7 +101,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
y: Math.min(maxPanY, Math.max(-maxPanY, nextPan.y)),
|
||||
};
|
||||
},
|
||||
[image.height, image.width, width, zoom]
|
||||
[image.height, image.width, width, zoom],
|
||||
);
|
||||
|
||||
const applyZoomAt = React.useCallback(
|
||||
@@ -124,7 +122,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
setZoom(clampedZoom);
|
||||
setPan(clampPan(nextPan, clampedZoom));
|
||||
},
|
||||
[clampPan, getViewportCenter, pan.x, pan.y, zoom]
|
||||
[clampPan, getViewportCenter, pan.x, pan.y, zoom],
|
||||
);
|
||||
|
||||
const startSwipe = React.useCallback((touch: React.Touch) => {
|
||||
@@ -162,7 +160,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
startSwipe(touch);
|
||||
tapStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
},
|
||||
[startSwipe, zoom]
|
||||
[startSwipe, zoom],
|
||||
);
|
||||
|
||||
const onTouchEnd = React.useCallback(
|
||||
@@ -171,8 +169,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
const tapStart = tapStartRef.current;
|
||||
const now = Date.now();
|
||||
const isTap =
|
||||
tapStart &&
|
||||
Math.hypot(tapStart.x - touch.clientX, tapStart.y - touch.clientY) < 10;
|
||||
tapStart && Math.hypot(tapStart.x - touch.clientX, tapStart.y - touch.clientY) < 10;
|
||||
const lastTap = lastTapRef.current;
|
||||
|
||||
if (isTap && lastTap && now - lastTap.time < 350) {
|
||||
@@ -182,10 +179,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
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 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);
|
||||
@@ -242,7 +236,17 @@ export const BigPicture: React.FC<Props> = ({
|
||||
setDragOffset(0);
|
||||
}
|
||||
},
|
||||
[applyZoomAt, goNext, goPrevious, image.height, image.width, pinchState, touchStart, width, zoom]
|
||||
[
|
||||
applyZoomAt,
|
||||
goNext,
|
||||
goPrevious,
|
||||
image.height,
|
||||
image.width,
|
||||
pinchState,
|
||||
touchStart,
|
||||
width,
|
||||
zoom,
|
||||
],
|
||||
);
|
||||
|
||||
const onTouchMove = React.useCallback(
|
||||
@@ -272,8 +276,8 @@ export const BigPicture: React.FC<Props> = ({
|
||||
x: prev.x + (touch.clientX - panStart.x),
|
||||
y: prev.y + (touch.clientY - panStart.y),
|
||||
},
|
||||
zoom
|
||||
)
|
||||
zoom,
|
||||
),
|
||||
);
|
||||
setPanStart({ x: touch.clientX, y: touch.clientY });
|
||||
return;
|
||||
@@ -292,7 +296,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
}
|
||||
setDragOffset(dx);
|
||||
},
|
||||
[applyZoomAt, clampPan, panStart, pinchState, touchStart, zoom]
|
||||
[applyZoomAt, clampPan, panStart, pinchState, touchStart, zoom],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -327,7 +331,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
}
|
||||
applyZoomAt(nextZoom, focal);
|
||||
},
|
||||
[applyZoomAt, zoom]
|
||||
[applyZoomAt, zoom],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -352,8 +356,8 @@ export const BigPicture: React.FC<Props> = ({
|
||||
x: prev.x + e.movementX,
|
||||
y: prev.y + e.movementY,
|
||||
},
|
||||
zoom
|
||||
)
|
||||
zoom,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -377,7 +381,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
e.preventDefault();
|
||||
setIsPointerPanning(true);
|
||||
},
|
||||
[zoom]
|
||||
[zoom],
|
||||
);
|
||||
|
||||
const slideWidth = width;
|
||||
@@ -404,27 +408,18 @@ export const BigPicture: React.FC<Props> = ({
|
||||
onTouchEnd={onTouchEnd}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div
|
||||
className={`BigPicture-track${isDragging ? " is-dragging" : ""}`}
|
||||
style={trackStyle}
|
||||
>
|
||||
<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-frame" key={img.src} style={slideStyle}>
|
||||
<div
|
||||
className="BigPicture-imageWrapper"
|
||||
style={
|
||||
isCurrent
|
||||
? { transform: `translate3d(${pan.x}px, ${pan.y}px, 0)` }
|
||||
: undefined
|
||||
isCurrent ? { transform: `translate3d(${pan.x}px, ${pan.y}px, 0)` } : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import { Picture } from "components/picture";
|
||||
import * as Model from "@/model";
|
||||
import { Picture } from "@/components/picture";
|
||||
|
||||
export interface Props {
|
||||
images: Model.Image[];
|
||||
@@ -30,13 +30,7 @@ const badness = (row: Model.Image[], width: number, height: number): number => {
|
||||
return (rowHeight - height) * (rowHeight - height);
|
||||
};
|
||||
|
||||
export const Grid: React.FC<Props> = ({
|
||||
images,
|
||||
onImageSelected,
|
||||
pageBottom,
|
||||
width,
|
||||
height,
|
||||
}) => {
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import { Grid } from "components/grid";
|
||||
import * as Model from "@/model";
|
||||
import { Grid } from "@/components/grid";
|
||||
|
||||
export interface Props {
|
||||
imageSet: Model.ImageSet;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import * as Model from "@/model";
|
||||
|
||||
export interface Props {
|
||||
image: Model.Image;
|
||||
@@ -9,20 +9,7 @@ export interface Props {
|
||||
defer?: boolean;
|
||||
}
|
||||
|
||||
interface SrcSetInfo {
|
||||
jpeg: string;
|
||||
webp: string;
|
||||
avif: string;
|
||||
bestSrc: string;
|
||||
}
|
||||
|
||||
export const Picture: React.FC<Props> = ({
|
||||
image,
|
||||
onClick,
|
||||
height,
|
||||
width,
|
||||
defer,
|
||||
}) => {
|
||||
export const Picture: React.FC<Props> = ({ image, onClick, height, width, defer }) => {
|
||||
const [isMounted, setIsMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -37,8 +24,7 @@ export const Picture: React.FC<Props> = ({
|
||||
let bestScale = Infinity;
|
||||
|
||||
Model.SIZES.forEach((size) => {
|
||||
const derivedWidth =
|
||||
image.width > image.height ? size : (image.width / image.height) * size;
|
||||
const derivedWidth = image.width > image.height ? size : (image.width / image.height) * size;
|
||||
|
||||
const scale = derivedWidth / width;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
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 {}
|
||||
|
||||
@@ -34,13 +34,9 @@ const formatHash = (set: Model.ImageSet) =>
|
||||
set.description.replace(/[^a-zA-Z0-9-_]/g, "-");
|
||||
|
||||
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 [data] = React.useState<Model.Data>(Model.data);
|
||||
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 {
|
||||
@@ -91,26 +87,7 @@ export const Root: React.FC<Props> = () => {
|
||||
}, [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();
|
||||
}
|
||||
loadHash();
|
||||
}, [data, loadHash]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -142,10 +119,7 @@ export const Root: React.FC<Props> = () => {
|
||||
React.useEffect(() => {
|
||||
if (selectedSet) {
|
||||
document.title =
|
||||
selectedSet.location +
|
||||
" – " +
|
||||
selectedSet.description +
|
||||
" – Skiing - Aaron Gutierrez";
|
||||
selectedSet.location + " – " + selectedSet.description + " – Skiing - Aaron Gutierrez";
|
||||
} else {
|
||||
document.title = "Skiing - Aaron Gutierrez";
|
||||
}
|
||||
@@ -160,7 +134,7 @@ export const Root: React.FC<Props> = () => {
|
||||
}
|
||||
setSelectedImage(img);
|
||||
},
|
||||
[selectedImage]
|
||||
[selectedImage],
|
||||
);
|
||||
|
||||
const onSetSelected = React.useCallback((set: Model.ImageSet) => {
|
||||
@@ -217,9 +191,6 @@ export const Root: React.FC<Props> = () => {
|
||||
}, [selectedImage, selectedSet]);
|
||||
|
||||
const renderSets = () => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
if (selectedSet) {
|
||||
return (
|
||||
<ImageSet
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as Model from "model";
|
||||
import { Picture } from "components/picture";
|
||||
import * as Model from "@/model";
|
||||
import { Picture } from "@/components/picture";
|
||||
|
||||
export interface Props {
|
||||
imageSet: Model.ImageSet;
|
||||
@@ -12,22 +12,13 @@ export const SetCover: React.FC<Props> = ({ imageSet, onClick, width }) => {
|
||||
const coverImage = imageSet.images[0];
|
||||
const isTall = coverImage.height > coverImage.width;
|
||||
|
||||
const height = isTall
|
||||
? width
|
||||
: (coverImage.height / coverImage.width) * width;
|
||||
const height = isTall ? width : (coverImage.height / coverImage.width) * width;
|
||||
|
||||
const normalizedWidth = isTall
|
||||
? (coverImage.width / coverImage.height) * width
|
||||
: width;
|
||||
const normalizedWidth = isTall ? (coverImage.width / coverImage.height) * width : width;
|
||||
|
||||
return (
|
||||
<div className="SetCover" onClick={onClick}>
|
||||
<Picture
|
||||
image={coverImage}
|
||||
onClick={() => {}}
|
||||
height={height}
|
||||
width={normalizedWidth}
|
||||
/>
|
||||
<Picture image={coverImage} onClick={() => {}} height={height} width={normalizedWidth} />
|
||||
<h2>
|
||||
<span className="SetCover-location">{imageSet.location}</span>
|
||||
<span className="SetCover-description">{imageSet.description}</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Root } from "components/root";
|
||||
import "@/styles.css";
|
||||
import { Root } from "@/components/root";
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const SIZES = [2400, 1600, 1200, 800, 600, 400, 200];
|
||||
import galleryData from "../img/data.json";
|
||||
|
||||
export const dataUrl = "img/data.json";
|
||||
export const SIZES = [2400, 1600, 1200, 800, 600, 400, 200];
|
||||
|
||||
export interface Data {
|
||||
sets: ImageSet[];
|
||||
@@ -17,3 +17,5 @@ export interface Image {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const data = galleryData as Data;
|
||||
|
||||
213
src/styles.css
Normal file
213
src/styles.css
Normal file
@@ -0,0 +1,213 @@
|
||||
html,
|
||||
body {
|
||||
background-color: #fff;
|
||||
color: #335;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial,
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body.no-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #69c;
|
||||
cursor: pointer;
|
||||
font-size: 45px;
|
||||
font-weight: lighter;
|
||||
line-height: 60px;
|
||||
margin: 30px 30px 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #666;
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
line-height: 30px;
|
||||
margin: 0 30px 30px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #69c;
|
||||
font-size: 18px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ImageSet h2 {
|
||||
border-bottom: 1px solid #eef;
|
||||
}
|
||||
|
||||
.Root-setCovers {
|
||||
display: grid;
|
||||
gap: 30px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.SetCover {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ImageSet-location,
|
||||
.ImageSet-description,
|
||||
.SetCover-location,
|
||||
.SetCover-description {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ImageSet-location:after,
|
||||
.SetCover-location:after {
|
||||
content: " · ";
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.ImageSet-navigation {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.Grid {
|
||||
margin-bottom: 45px;
|
||||
}
|
||||
|
||||
.Grid-row {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.Grid img {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.BigPicture {
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.BigPicture-viewport {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.BigPicture-track {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
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 {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.BigPicture-zoomTarget {
|
||||
transition: transform 0.2s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.BigPicture img {
|
||||
transition-duration: 0.2s;
|
||||
transition-property: height, width, opacity;
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.BigPicture-footer {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.BigPicture-footerLink {
|
||||
color: #69c;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 0 18px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.BigPicture-footerLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html,
|
||||
body {
|
||||
background-color: #111;
|
||||
color: #ccf;
|
||||
}
|
||||
|
||||
.ImageSet h2 {
|
||||
border-color: #335;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.Root-setCovers {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.Root-setCovers {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Skiing – Aaron Gutierrez</title>
|
||||
<link rel="stylesheet" href="{0}">
|
||||
<meta charshet="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta property="og:title" content="Aaron's Ski Pictures">
|
||||
<meta property="og:description" content="Mostly ski pictures, mostly in the backcountry.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://ski.aarongutierrez.com">
|
||||
<meta property="og:image" content="https://ski.aarongutierrez.com/img/1600/112873ef54777a15bd9e5cd03362d5a5.jpg">
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content="1600">
|
||||
<meta property="og:image:height" content="1067">
|
||||
</head>
|
||||
<body>
|
||||
<div id="mount"><noscript>I don't like javascript, either.</noscript></div>
|
||||
<script type="text/javascript" src="{1}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"baseUrl": "./src",
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"target": "ES2020",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
]
|
||||
"include": ["./src/**/*", "./scripts/**/*", "./vite.config.ts"]
|
||||
}
|
||||
|
||||
26
vite.config.ts
Normal file
26
vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vite-plus";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
staged: {
|
||||
"*": "vp check --fix",
|
||||
},
|
||||
fmt: {},
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
// React Compiler must run first in the Babel pipeline.
|
||||
plugins: ["babel-plugin-react-compiler"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(rootDir, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
var path = require("path");
|
||||
|
||||
module.exports = (env) => {
|
||||
const mode = env === "dev" ? "development" : "production";
|
||||
|
||||
return {
|
||||
entry: "./src/index.tsx",
|
||||
output: {
|
||||
filename: "bundle.js",
|
||||
path: path.join(__dirname, "dist"),
|
||||
clean: true,
|
||||
},
|
||||
|
||||
mode: mode,
|
||||
target: "web",
|
||||
|
||||
// Enable sourcemaps for debugging webpack's output.
|
||||
devtool: "source-map",
|
||||
|
||||
devServer: {
|
||||
static: {
|
||||
directory: __dirname,
|
||||
},
|
||||
port: 8080
|
||||
},
|
||||
|
||||
resolve: {
|
||||
// Add '.ts' and '.tsx' as resolvable extensions.
|
||||
extensions: [".ts", ".tsx", ".js", ".json"],
|
||||
modules: [path.resolve(__dirname, "src"), "node_modules"],
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{ test: /\.tsx?$/, loader: "ts-loader", exclude: /node_modules/, },
|
||||
|
||||
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" }
|
||||
]
|
||||
},
|
||||
|
||||
experiments: {
|
||||
topLevelAwait: true
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user