Migrate to Vite / Vite+
Modernize building and such
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,8 +2,6 @@
|
|||||||
*.swo
|
*.swo
|
||||||
dist/
|
dist/
|
||||||
img/*
|
img/*
|
||||||
!img/convert.sh
|
!img/data.json
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.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
|
||||||
|
|
||||||
## [ski.aarongutierrez.com](https://ski.aarongutierrez.com)
|
## [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">
|
<html lang="en-US">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Skiing - Aaron Gutierrez</title>
|
<title>Skiing - Aaron Gutierrez</title>
|
||||||
<link rel="stylesheet" href="site.css">
|
<meta property="og:title" content="Aaron's Ski Pictures" />
|
||||||
<meta charset="utf-8">
|
<meta property="og:description" content="Mostly ski pictures, mostly in the backcountry." />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="mount"><noscript>I don't like javascript, either.</noscript></div>
|
<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>
|
</body>
|
||||||
</html>
|
</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",
|
"name": "ski",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Ski Photos",
|
"description": "Ski Photos",
|
||||||
"main": "index.js",
|
"license": "ISC",
|
||||||
"scripts": {
|
"author": "Aaron Gutierrez",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
|
||||||
"prettier": "prettier --write $(git ls-files \"*.ts*\")",
|
|
||||||
"dev": "webpack-dev-server --env dev"
|
|
||||||
},
|
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git@git.frat.tech:aaron/ski.git"
|
"url": "git@git.frat.tech:aaron/ski.git"
|
||||||
},
|
},
|
||||||
"author": "Aaron Gutierrez",
|
"type": "module",
|
||||||
"license": "ISC",
|
"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": {
|
"dependencies": {
|
||||||
"react": "^19.1.0",
|
"@aws-sdk/client-cloudfront": "^3.888.0",
|
||||||
"react-dom": "^19.1.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": {
|
"devDependencies": {
|
||||||
"@types/react": "19.1.0",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react-dom": "19.1.0",
|
"@types/node": "^24.5.2",
|
||||||
"esbuild": "^0.25.4",
|
"@types/react": "^19.1.12",
|
||||||
"source-map-loader": "^5.0.0",
|
"@types/react-dom": "^19.1.9",
|
||||||
"ts-loader": "^9.5.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"typescript": "^5.8.3",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"webpack": "^5.99.9",
|
"typescript": "^5.9.2",
|
||||||
"webpack-cli": "^6.0.1",
|
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||||
"webpack-dev-server": "^5.2.1"
|
"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 React from "react";
|
||||||
import * as Model from "model";
|
import * as Model from "@/model";
|
||||||
import { Picture } from "components/picture";
|
import { Picture } from "@/components/picture";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
image: Model.Image;
|
image: Model.Image;
|
||||||
@@ -48,9 +48,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
const [pinchState, setPinchState] = React.useState<PinchState | null>(null);
|
const [pinchState, setPinchState] = React.useState<PinchState | null>(null);
|
||||||
const [isPointerPanning, setIsPointerPanning] = React.useState(false);
|
const [isPointerPanning, setIsPointerPanning] = React.useState(false);
|
||||||
const tapStartRef = React.useRef<Point | null>(null);
|
const tapStartRef = React.useRef<Point | null>(null);
|
||||||
const lastTapRef = React.useRef<{ time: number; x: number; y: number } | null>(
|
const lastTapRef = React.useRef<{ time: number; x: number; y: number } | null>(null);
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const onEscape = React.useCallback(
|
const onEscape = React.useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
@@ -58,7 +56,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClose]
|
[onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const goNext = React.useCallback(() => {
|
const goNext = React.useCallback(() => {
|
||||||
@@ -103,7 +101,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
y: Math.min(maxPanY, Math.max(-maxPanY, nextPan.y)),
|
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(
|
const applyZoomAt = React.useCallback(
|
||||||
@@ -124,7 +122,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
setZoom(clampedZoom);
|
setZoom(clampedZoom);
|
||||||
setPan(clampPan(nextPan, 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) => {
|
const startSwipe = React.useCallback((touch: React.Touch) => {
|
||||||
@@ -162,7 +160,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
startSwipe(touch);
|
startSwipe(touch);
|
||||||
tapStartRef.current = { x: touch.clientX, y: touch.clientY };
|
tapStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||||
},
|
},
|
||||||
[startSwipe, zoom]
|
[startSwipe, zoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onTouchEnd = React.useCallback(
|
const onTouchEnd = React.useCallback(
|
||||||
@@ -171,8 +169,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
const tapStart = tapStartRef.current;
|
const tapStart = tapStartRef.current;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const isTap =
|
const isTap =
|
||||||
tapStart &&
|
tapStart && Math.hypot(tapStart.x - touch.clientX, tapStart.y - touch.clientY) < 10;
|
||||||
Math.hypot(tapStart.x - touch.clientX, tapStart.y - touch.clientY) < 10;
|
|
||||||
const lastTap = lastTapRef.current;
|
const lastTap = lastTapRef.current;
|
||||||
|
|
||||||
if (isTap && lastTap && now - lastTap.time < 350) {
|
if (isTap && lastTap && now - lastTap.time < 350) {
|
||||||
@@ -182,10 +179,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
const focal = rect
|
const focal = rect
|
||||||
? { x: touch.clientX - rect.left, y: touch.clientY - rect.top }
|
? { x: touch.clientX - rect.left, y: touch.clientY - rect.top }
|
||||||
: undefined;
|
: undefined;
|
||||||
const fitScale = Math.max(
|
const fitScale = Math.max(image.width / width, image.height / (window.innerHeight - 80));
|
||||||
image.width / width,
|
|
||||||
image.height / (window.innerHeight - 80)
|
|
||||||
);
|
|
||||||
const targetZoom = zoom > 1.05 ? 1 : Math.min(4, fitScale);
|
const targetZoom = zoom > 1.05 ? 1 : Math.min(4, fitScale);
|
||||||
applyZoomAt(targetZoom, focal);
|
applyZoomAt(targetZoom, focal);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
@@ -242,7 +236,17 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
setDragOffset(0);
|
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(
|
const onTouchMove = React.useCallback(
|
||||||
@@ -272,8 +276,8 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
x: prev.x + (touch.clientX - panStart.x),
|
x: prev.x + (touch.clientX - panStart.x),
|
||||||
y: prev.y + (touch.clientY - panStart.y),
|
y: prev.y + (touch.clientY - panStart.y),
|
||||||
},
|
},
|
||||||
zoom
|
zoom,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
setPanStart({ x: touch.clientX, y: touch.clientY });
|
setPanStart({ x: touch.clientX, y: touch.clientY });
|
||||||
return;
|
return;
|
||||||
@@ -292,7 +296,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
setDragOffset(dx);
|
setDragOffset(dx);
|
||||||
},
|
},
|
||||||
[applyZoomAt, clampPan, panStart, pinchState, touchStart, zoom]
|
[applyZoomAt, clampPan, panStart, pinchState, touchStart, zoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -327,7 +331,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
applyZoomAt(nextZoom, focal);
|
applyZoomAt(nextZoom, focal);
|
||||||
},
|
},
|
||||||
[applyZoomAt, zoom]
|
[applyZoomAt, zoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -352,8 +356,8 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
x: prev.x + e.movementX,
|
x: prev.x + e.movementX,
|
||||||
y: prev.y + e.movementY,
|
y: prev.y + e.movementY,
|
||||||
},
|
},
|
||||||
zoom
|
zoom,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -377,7 +381,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsPointerPanning(true);
|
setIsPointerPanning(true);
|
||||||
},
|
},
|
||||||
[zoom]
|
[zoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const slideWidth = width;
|
const slideWidth = width;
|
||||||
@@ -404,27 +408,18 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
>
|
>
|
||||||
<div
|
<div className={`BigPicture-track${isDragging ? " is-dragging" : ""}`} style={trackStyle}>
|
||||||
className={`BigPicture-track${isDragging ? " is-dragging" : ""}`}
|
|
||||||
style={trackStyle}
|
|
||||||
>
|
|
||||||
{[previousImage, image, nextImage].map((img) => {
|
{[previousImage, image, nextImage].map((img) => {
|
||||||
const imgScaleWidth = img.width / width;
|
const imgScaleWidth = img.width / width;
|
||||||
const imgScaleHeight = img.height / (window.innerHeight - 80);
|
const imgScaleHeight = img.height / (window.innerHeight - 80);
|
||||||
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
|
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
|
||||||
const isCurrent = img.src === image.src;
|
const isCurrent = img.src === image.src;
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="BigPicture-frame" key={img.src} style={slideStyle}>
|
||||||
className="BigPicture-frame"
|
|
||||||
key={img.src}
|
|
||||||
style={slideStyle}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="BigPicture-imageWrapper"
|
className="BigPicture-imageWrapper"
|
||||||
style={
|
style={
|
||||||
isCurrent
|
isCurrent ? { transform: `translate3d(${pan.x}px, ${pan.y}px, 0)` } : undefined
|
||||||
? { transform: `translate3d(${pan.x}px, ${pan.y}px, 0)` }
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as Model from "model";
|
import * as Model from "@/model";
|
||||||
import { Picture } from "components/picture";
|
import { Picture } from "@/components/picture";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
images: Model.Image[];
|
images: Model.Image[];
|
||||||
@@ -30,13 +30,7 @@ const badness = (row: Model.Image[], width: number, height: number): number => {
|
|||||||
return (rowHeight - height) * (rowHeight - height);
|
return (rowHeight - height) * (rowHeight - height);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Grid: React.FC<Props> = ({
|
export const Grid: React.FC<Props> = ({ images, onImageSelected, pageBottom, width, height }) => {
|
||||||
images,
|
|
||||||
onImageSelected,
|
|
||||||
pageBottom,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}) => {
|
|
||||||
const rowsMemo = React.useRef<Map<number, Map<number, BadList>>>(new Map());
|
const rowsMemo = React.useRef<Map<number, Map<number, BadList>>>(new Map());
|
||||||
const targetRowHeight = width > 900 ? ROW_HEIGHT : MOBILE_ROW_HEIGHT;
|
const targetRowHeight = width > 900 ? ROW_HEIGHT : MOBILE_ROW_HEIGHT;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as Model from "model";
|
import * as Model from "@/model";
|
||||||
import { Grid } from "components/grid";
|
import { Grid } from "@/components/grid";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
imageSet: Model.ImageSet;
|
imageSet: Model.ImageSet;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as Model from "model";
|
import * as Model from "@/model";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
image: Model.Image;
|
image: Model.Image;
|
||||||
@@ -9,20 +9,7 @@ export interface Props {
|
|||||||
defer?: boolean;
|
defer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SrcSetInfo {
|
export const Picture: React.FC<Props> = ({ image, onClick, height, width, defer }) => {
|
||||||
jpeg: string;
|
|
||||||
webp: string;
|
|
||||||
avif: string;
|
|
||||||
bestSrc: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Picture: React.FC<Props> = ({
|
|
||||||
image,
|
|
||||||
onClick,
|
|
||||||
height,
|
|
||||||
width,
|
|
||||||
defer,
|
|
||||||
}) => {
|
|
||||||
const [isMounted, setIsMounted] = React.useState(false);
|
const [isMounted, setIsMounted] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -37,8 +24,7 @@ export const Picture: React.FC<Props> = ({
|
|||||||
let bestScale = Infinity;
|
let bestScale = Infinity;
|
||||||
|
|
||||||
Model.SIZES.forEach((size) => {
|
Model.SIZES.forEach((size) => {
|
||||||
const derivedWidth =
|
const derivedWidth = image.width > image.height ? size : (image.width / image.height) * size;
|
||||||
image.width > image.height ? size : (image.width / image.height) * size;
|
|
||||||
|
|
||||||
const scale = derivedWidth / width;
|
const scale = derivedWidth / width;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as Model from "model";
|
import * as Model from "@/model";
|
||||||
import { BigPicture } from "components/big_picture";
|
import { BigPicture } from "@/components/big_picture";
|
||||||
import { ImageSet } from "components/image_set";
|
import { ImageSet } from "@/components/image_set";
|
||||||
import { SetCover } from "components/set_cover";
|
import { SetCover } from "@/components/set_cover";
|
||||||
|
|
||||||
export interface Props {}
|
export interface Props {}
|
||||||
|
|
||||||
@@ -34,13 +34,9 @@ const formatHash = (set: Model.ImageSet) =>
|
|||||||
set.description.replace(/[^a-zA-Z0-9-_]/g, "-");
|
set.description.replace(/[^a-zA-Z0-9-_]/g, "-");
|
||||||
|
|
||||||
export const Root: React.FC<Props> = () => {
|
export const Root: React.FC<Props> = () => {
|
||||||
const [data, setData] = React.useState<Model.Data | null>(null);
|
const [data] = React.useState<Model.Data>(Model.data);
|
||||||
const [selectedImage, setSelectedImage] = React.useState<Model.Image | null>(
|
const [selectedImage, setSelectedImage] = React.useState<Model.Image | null>(null);
|
||||||
null
|
const [selectedSet, setSelectedSet] = React.useState<Model.ImageSet | null>(null);
|
||||||
);
|
|
||||||
const [selectedSet, setSelectedSet] = React.useState<Model.ImageSet | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [dimensions, setDimensions] = React.useState(() => {
|
const [dimensions, setDimensions] = React.useState(() => {
|
||||||
const height = viewHeight();
|
const height = viewHeight();
|
||||||
return {
|
return {
|
||||||
@@ -91,26 +87,7 @@ export const Root: React.FC<Props> = () => {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let isMounted = true;
|
loadHash();
|
||||||
window
|
|
||||||
.fetch(Model.dataUrl)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((json) => {
|
|
||||||
if (isMounted) {
|
|
||||||
setData(json);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => console.error("Error fetching data", e));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
loadHash();
|
|
||||||
}
|
|
||||||
}, [data, loadHash]);
|
}, [data, loadHash]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -142,10 +119,7 @@ export const Root: React.FC<Props> = () => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (selectedSet) {
|
if (selectedSet) {
|
||||||
document.title =
|
document.title =
|
||||||
selectedSet.location +
|
selectedSet.location + " – " + selectedSet.description + " – Skiing - Aaron Gutierrez";
|
||||||
" – " +
|
|
||||||
selectedSet.description +
|
|
||||||
" – Skiing - Aaron Gutierrez";
|
|
||||||
} else {
|
} else {
|
||||||
document.title = "Skiing - Aaron Gutierrez";
|
document.title = "Skiing - Aaron Gutierrez";
|
||||||
}
|
}
|
||||||
@@ -160,7 +134,7 @@ export const Root: React.FC<Props> = () => {
|
|||||||
}
|
}
|
||||||
setSelectedImage(img);
|
setSelectedImage(img);
|
||||||
},
|
},
|
||||||
[selectedImage]
|
[selectedImage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSetSelected = React.useCallback((set: Model.ImageSet) => {
|
const onSetSelected = React.useCallback((set: Model.ImageSet) => {
|
||||||
@@ -217,9 +191,6 @@ export const Root: React.FC<Props> = () => {
|
|||||||
}, [selectedImage, selectedSet]);
|
}, [selectedImage, selectedSet]);
|
||||||
|
|
||||||
const renderSets = () => {
|
const renderSets = () => {
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (selectedSet) {
|
if (selectedSet) {
|
||||||
return (
|
return (
|
||||||
<ImageSet
|
<ImageSet
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as Model from "model";
|
import * as Model from "@/model";
|
||||||
import { Picture } from "components/picture";
|
import { Picture } from "@/components/picture";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
imageSet: Model.ImageSet;
|
imageSet: Model.ImageSet;
|
||||||
@@ -12,22 +12,13 @@ export const SetCover: React.FC<Props> = ({ imageSet, onClick, width }) => {
|
|||||||
const coverImage = imageSet.images[0];
|
const coverImage = imageSet.images[0];
|
||||||
const isTall = coverImage.height > coverImage.width;
|
const isTall = coverImage.height > coverImage.width;
|
||||||
|
|
||||||
const height = isTall
|
const height = isTall ? width : (coverImage.height / coverImage.width) * width;
|
||||||
? width
|
|
||||||
: (coverImage.height / coverImage.width) * width;
|
|
||||||
|
|
||||||
const normalizedWidth = isTall
|
const normalizedWidth = isTall ? (coverImage.width / coverImage.height) * width : width;
|
||||||
? (coverImage.width / coverImage.height) * width
|
|
||||||
: width;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="SetCover" onClick={onClick}>
|
<div className="SetCover" onClick={onClick}>
|
||||||
<Picture
|
<Picture image={coverImage} onClick={() => {}} height={height} width={normalizedWidth} />
|
||||||
image={coverImage}
|
|
||||||
onClick={() => {}}
|
|
||||||
height={height}
|
|
||||||
width={normalizedWidth}
|
|
||||||
/>
|
|
||||||
<h2>
|
<h2>
|
||||||
<span className="SetCover-location">{imageSet.location}</span>
|
<span className="SetCover-location">{imageSet.location}</span>
|
||||||
<span className="SetCover-description">{imageSet.description}</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";
|
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 {
|
export interface Data {
|
||||||
sets: ImageSet[];
|
sets: ImageSet[];
|
||||||
@@ -17,3 +17,5 @@ export interface Image {
|
|||||||
height: number;
|
height: number;
|
||||||
width: 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": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist/",
|
|
||||||
"baseUrl": "./src",
|
|
||||||
"sourceMap": true,
|
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["./src/**/*", "./scripts/**/*", "./vite.config.ts"]
|
||||||
"./src/**/*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
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