Migrate to Vite / Vite+

Modernize building and such
This commit is contained in:
2026-04-03 21:12:56 -07:00
parent 445f95340a
commit f04fd0fada
26 changed files with 8080 additions and 4180 deletions

4
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1 @@
vp staged

88
AGENTS.md Normal file
View 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-->

View File

@@ -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.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

@@ -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
View 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
View 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
View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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";

View File

@@ -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
View 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;
}
}

View File

@@ -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>

View File

@@ -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
View 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"),
},
},
});

View File

@@ -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
}
}
};