Compare commits
No commits in common. "5bdf68eabdcf4f066d7fa7982e613f4dae6f521f" and "ff6c7f6f68077ef3c6cb44070694d9cfbf976b7b" have entirely different histories.
5bdf68eabd
...
ff6c7f6f68
|
|
@ -7,15 +7,12 @@ on:
|
||||||
branches: ['main']
|
branches: ['main']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: 'photos-${{ github.ref }}'
|
group: 'pages'
|
||||||
cancel-in-progress: true
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
build:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -24,48 +21,18 @@ jobs:
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version: '12'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: |
|
||||||
|
npm install
|
||||||
- name: Lint and static checks
|
|
||||||
run: npm run lint:check
|
|
||||||
|
|
||||||
- name: Unit tests
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: |
|
||||||
|
npm run build
|
||||||
|
|
||||||
- name: Install Playwright browser
|
- name: Copy files to nginx pages mount
|
||||||
run: npx playwright install --with-deps chromium
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
- name: End-to-end smoke test
|
|
||||||
run: npm run test:e2e
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: docker
|
|
||||||
needs: validate
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: '.nvmrc'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Copy build to host pages mount
|
|
||||||
run: |
|
run: |
|
||||||
apt update && apt install -y rsync
|
apt update && apt install -y rsync
|
||||||
rsync -a --delete --mkpath --chmod=ugo=rwx dist/ /pages/photos/
|
rsync -a --delete --mkpath --chmod=ugo=rwx dist/ /pages/photos/
|
||||||
|
|
|
||||||
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
og-image.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
5
.gitignore
vendored
|
|
@ -1,8 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
target
|
target
|
||||||
|
package-lock.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
public/static/photos
|
|
||||||
src/generated
|
|
||||||
playwright-report
|
|
||||||
test-results
|
|
||||||
|
|
|
||||||
1
.nvmrc
|
|
@ -1 +0,0 @@
|
||||||
22.13.0
|
|
||||||
81
README.md
|
|
@ -1,82 +1 @@
|
||||||
# Photos
|
# Photos
|
||||||
|
|
||||||
A static, progressively enhanced photo portfolio for András Schmelczer.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Node `22.13.0` or newer compatible with `.nvmrc`
|
|
||||||
- npm `10.9.0` or newer
|
|
||||||
|
|
||||||
Install with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm ci
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The dev server binds to `127.0.0.1` by default. This generates responsive image assets and the typed photo manifest before Vite starts.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
The build writes to `dist/`. Static files come from `public/`, and responsive photo assets are generated into `public/static/photos/`.
|
|
||||||
|
|
||||||
## Quality Gates
|
|
||||||
|
|
||||||
Run the full local validation suite with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run lint:check
|
|
||||||
```
|
|
||||||
|
|
||||||
This checks the photo catalog, generates assets, checks source photos for EXIF/XMP/IPTC metadata, type-checks, lints TypeScript and SCSS, and checks formatting.
|
|
||||||
|
|
||||||
Run unit tests separately:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `npm run lint` to apply Prettier formatting and auto-fixable ESLint/Stylelint issues.
|
|
||||||
|
|
||||||
End-to-end smoke tests are available separately:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
Playwright requires a browser installation. CI installs Chromium before running the smoke test.
|
|
||||||
|
|
||||||
## Photo Catalog
|
|
||||||
|
|
||||||
Photo order, captions, and alt text live in `src/photo-catalog.json`. Add new source JPEGs to `src/pictures/`, then add a matching catalog entry. `npm run dev`, `npm run lint:check`, and `npm run build` validate that the catalog and source directory match.
|
|
||||||
|
|
||||||
The generated files under `src/generated/` and `public/static/photos/` are intentionally ignored because they are reproducible from the source images and catalog.
|
|
||||||
|
|
||||||
## Metadata
|
|
||||||
|
|
||||||
Source photos should not contain private EXIF/XMP/IPTC metadata. Check with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run lint:check
|
|
||||||
```
|
|
||||||
|
|
||||||
Strip metadata with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
node scripts/strip-image-metadata.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
Review image changes after stripping because this rewrites the JPEG files.
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
The Forgejo workflow validates pull requests and trusted pushes with `npm ci`, `npm run lint:check`, `npm test`, `npm run build`, and a Playwright smoke test. Only pushes to `main` deploy, by copying `dist/` to `/pages/photos/`.
|
|
||||||
|
|
|
||||||
26
custom.d.ts
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
declare module '*.svg' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpg' {
|
||||||
|
import { ResponsiveImage } from 'src/model/responsive-image';
|
||||||
|
const content: ResponsiveImage;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpeg' {
|
||||||
|
import { ResponsiveImage } from 'src/model/responsive-image';
|
||||||
|
const content: ResponsiveImage;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.txt' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.html' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
test('renders the gallery and supports keyboard navigation', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await expect(page.locator('.thumbnail')).toHaveCount(39);
|
|
||||||
await expect(page.locator('#frame-image')).toBeVisible();
|
|
||||||
|
|
||||||
const firstAlt = await page.locator('#frame-image').getAttribute('alt');
|
|
||||||
|
|
||||||
await page.locator('.thumbnail').first().focus();
|
|
||||||
await page.keyboard.press('ArrowRight');
|
|
||||||
|
|
||||||
await expect(page.locator('.thumbnail[aria-current="true"]')).toHaveCount(1);
|
|
||||||
await expect(page.locator('#frame-image')).not.toHaveAttribute(
|
|
||||||
'alt',
|
|
||||||
firstAlt ?? ''
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import js from '@eslint/js';
|
|
||||||
import globals from 'globals';
|
|
||||||
import tseslint from 'typescript-eslint';
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
ignores: [
|
|
||||||
'dist/**',
|
|
||||||
'public/static/photos/**',
|
|
||||||
'src/generated/**',
|
|
||||||
'playwright-report/**',
|
|
||||||
'test-results/**',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
js.configs.recommended,
|
|
||||||
...tseslint.configs.recommended,
|
|
||||||
{
|
|
||||||
files: ['**/*.ts'],
|
|
||||||
languageOptions: {
|
|
||||||
parser: tseslint.parser,
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'no-undef': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.js', '**/*.mjs'],
|
|
||||||
languageOptions: {
|
|
||||||
sourceType: 'module',
|
|
||||||
globals: {
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
BIN
og-image.jpg
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 131 B |
5495
package-lock.json
generated
74
package.json
|
|
@ -1,55 +1,61 @@
|
||||||
{
|
{
|
||||||
"name": "photos",
|
"name": "portfolio",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "A static, progressively enhanced photo portfolio.",
|
"description": "An easily configurable portfolio.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/generate-assets.mjs && vite --host 0.0.0.0",
|
"start": "webpack-dev-server --mode development",
|
||||||
"lint": "eslint . --fix && stylelint \"src/**/*.scss\" --fix && prettier --write .",
|
"build": "webpack && find dist -type f -regex \".*\\(js\\|css\\|LICENSE.*\\)\" | xargs rm"
|
||||||
"lint:check": "node scripts/check-photo-catalog.mjs && node scripts/generate-assets.mjs && node scripts/check-image-metadata.mjs && tsc --noEmit && eslint . && stylelint \"src/**/*.scss\" && prettier --check .",
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"build": "node scripts/generate-assets.mjs && vite build"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.13.0",
|
|
||||||
"npm": ">=10.9.0"
|
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/schmelczerandras/photos.git"
|
"url": "git+https://github.com/schmelczerandras/timeline.git"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
"CV",
|
||||||
"portfolio",
|
"portfolio",
|
||||||
"photography",
|
"resume",
|
||||||
"photos",
|
"resumé"
|
||||||
"gallery"
|
|
||||||
],
|
],
|
||||||
"author": "András Schmelczer",
|
"author": "András Schmelczer",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/schmelczerandras/photos/issues"
|
"url": "https://github.com/schmelczerandras/timeline/issues"
|
||||||
|
},
|
||||||
|
"postcss": {
|
||||||
|
"plugins": {
|
||||||
|
"autoprefixer": {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"homepage": "https://schmelczer.dev/photos/",
|
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"defaults"
|
"defaults"
|
||||||
],
|
],
|
||||||
|
"homepage": "https://github.com/schmelczerandras/timeline#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"autoprefixer": "^9.8.6",
|
||||||
"@playwright/test": "^1.60.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"@types/node": "^25.9.1",
|
"css-loader": "^3.6.0",
|
||||||
"eslint": "^10.4.1",
|
"cssnano": "latest",
|
||||||
"globals": "^16.5.0",
|
"file-loader": "^5.1.0",
|
||||||
"jsdom": "^29.1.1",
|
"html-webpack-inline-source-plugin": "0.0.10",
|
||||||
"prettier": "^3.8.3",
|
"html-webpack-plugin": "^3.2.0",
|
||||||
"sass": "^1.95.0",
|
"image-webpack-loader": "^6.0.0",
|
||||||
"sharp": "^0.34.5",
|
"mini-css-extract-plugin": "^0.9.0",
|
||||||
"stylelint": "^17.12.0",
|
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||||
"stylelint-config-standard-scss": "^17.0.0",
|
"postcss-loader": "^3.0.0",
|
||||||
"typescript": "^6.0.3",
|
"prettier": "^1.19.1",
|
||||||
"typescript-eslint": "^8.60.0",
|
"resolve-url-loader": "^3.1.1",
|
||||||
"vite": "^8.0.14",
|
"responsive-loader": "^1.2.0",
|
||||||
"vitest": "^4.1.7"
|
"sass": "^1.26.11",
|
||||||
|
"sass-loader": "^8.0.2",
|
||||||
|
"sharp": "^0.23.4",
|
||||||
|
"style-loader": "^1.2.1",
|
||||||
|
"svg-url-loader": "^3.0.3",
|
||||||
|
"terser-webpack-plugin": "^2.3.8",
|
||||||
|
"ts-loader": "^6.2.2",
|
||||||
|
"typescript": "^3.9.7",
|
||||||
|
"webpack": "^4.44.2",
|
||||||
|
"webpack-cli": "^3.3.12",
|
||||||
|
"webpack-dev-server": "^3.11.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: 'e2e',
|
|
||||||
timeout: 30_000,
|
|
||||||
use: {
|
|
||||||
baseURL: 'http://127.0.0.1:4173',
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
},
|
|
||||||
webServer: {
|
|
||||||
command: 'npm run build && npx vite preview --host 127.0.0.1',
|
|
||||||
url: 'http://127.0.0.1:4173',
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120_000,
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
Before Width: | Height: | Size: 9 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 538 B |
|
Before Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
|
@ -1,4 +0,0 @@
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://schmelczer.dev/photos/sitemap.xml
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Photos - András Schmelczer",
|
|
||||||
"short_name": "Photos",
|
|
||||||
"start_url": ".",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#eeeeee",
|
|
||||||
"theme_color": "#a63446",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url>
|
|
||||||
<loc>https://schmelczer.dev/photos/</loc>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { readdir } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const rootDir = path.resolve(__dirname, '..');
|
|
||||||
const pictureDir = path.join(rootDir, 'src/pictures');
|
|
||||||
|
|
||||||
const files = (await readdir(pictureDir)).filter((file) =>
|
|
||||||
file.toLowerCase().endsWith('.jpg')
|
|
||||||
);
|
|
||||||
|
|
||||||
const offenders = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const metadata = await sharp(path.join(pictureDir, file)).metadata();
|
|
||||||
|
|
||||||
if (metadata.exif || metadata.xmp || metadata.iptc) {
|
|
||||||
offenders.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offenders.length > 0) {
|
|
||||||
console.error('Private image metadata found in source photos:');
|
|
||||||
for (const file of offenders) {
|
|
||||||
console.error(`- ${file}`);
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
'Run `node scripts/strip-image-metadata.mjs` and review the image changes.'
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Checked ${files.length} source photos for EXIF/XMP/IPTC metadata.`
|
|
||||||
);
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import { readFile, readdir } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const rootDir = path.resolve(__dirname, '..');
|
|
||||||
const sourceDir = path.join(rootDir, 'src/pictures');
|
|
||||||
const catalogPath = path.join(rootDir, 'src/photo-catalog.json');
|
|
||||||
|
|
||||||
const isJpeg = (file) => /\.(jpe?g)$/i.test(file);
|
|
||||||
|
|
||||||
const formatList = (items) => items.map((item) => `- ${item}`).join('\n');
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const sourceFiles = (await readdir(sourceDir)).filter(isJpeg).sort();
|
|
||||||
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
|
|
||||||
|
|
||||||
if (!Array.isArray(catalog)) {
|
|
||||||
throw new Error('Photo catalog must be an array.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalogFiles = catalog.map((entry, index) => {
|
|
||||||
if (!entry || typeof entry !== 'object') {
|
|
||||||
throw new Error(`Photo catalog entry ${index + 1} must be an object.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof entry.file !== 'string' || entry.file.trim() === '') {
|
|
||||||
throw new Error(`Photo catalog entry ${index + 1} is missing "file".`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.file;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sourceFileSet = new Set(sourceFiles);
|
|
||||||
const catalogFileSet = new Set(catalogFiles);
|
|
||||||
const duplicateFiles = catalogFiles
|
|
||||||
.filter((file, index) => catalogFiles.indexOf(file) !== index)
|
|
||||||
.filter((file, index, files) => files.indexOf(file) === index)
|
|
||||||
.sort();
|
|
||||||
const missingFromCatalog = sourceFiles.filter(
|
|
||||||
(file) => !catalogFileSet.has(file)
|
|
||||||
);
|
|
||||||
const missingFromPictures = catalogFiles
|
|
||||||
.filter((file) => !sourceFileSet.has(file))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
if (
|
|
||||||
duplicateFiles.length > 0 ||
|
|
||||||
missingFromCatalog.length > 0 ||
|
|
||||||
missingFromPictures.length > 0
|
|
||||||
) {
|
|
||||||
const sections = [];
|
|
||||||
|
|
||||||
if (duplicateFiles.length > 0) {
|
|
||||||
sections.push(
|
|
||||||
`Duplicate catalog file entries:\n${formatList(duplicateFiles)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingFromCatalog.length > 0) {
|
|
||||||
sections.push(
|
|
||||||
`Photos missing from src/photo-catalog.json:\n${formatList(
|
|
||||||
missingFromCatalog
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingFromPictures.length > 0) {
|
|
||||||
sections.push(
|
|
||||||
`Catalog entries missing from src/pictures:\n${formatList(
|
|
||||||
missingFromPictures
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(sections.join('\n\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Photo catalog includes all ${sourceFiles.length} source photos.`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
await main();
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const rootDir = path.resolve(__dirname, '..');
|
|
||||||
const sourceDir = path.join(rootDir, 'src/pictures');
|
|
||||||
const publicPhotoDir = path.join(rootDir, 'public/static/photos');
|
|
||||||
const generatedDir = path.join(rootDir, 'src/generated');
|
|
||||||
const catalogPath = path.join(rootDir, 'src/photo-catalog.json');
|
|
||||||
const widths = [480, 960, 1600, 2400];
|
|
||||||
const formats = [
|
|
||||||
{
|
|
||||||
type: 'image/avif',
|
|
||||||
extension: 'avif',
|
|
||||||
options: { quality: 45, effort: 0 },
|
|
||||||
},
|
|
||||||
{ type: 'image/webp', extension: 'webp', options: { quality: 78 } },
|
|
||||||
{
|
|
||||||
type: 'image/jpeg',
|
|
||||||
extension: 'jpg',
|
|
||||||
options: { quality: 82, mozjpeg: true, progressive: true },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const slugify = (value) =>
|
|
||||||
value
|
|
||||||
.replace(/\.[^.]+$/, '')
|
|
||||||
.normalize('NFKD')
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[_\s]+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
|
|
||||||
const escapeHtml = (value) =>
|
|
||||||
value
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"');
|
|
||||||
|
|
||||||
const uniqueWidthsFor = (sourceWidth) => {
|
|
||||||
const maxOutputWidth = Math.min(sourceWidth, widths.at(-1));
|
|
||||||
const selected = widths.filter((width) => width <= maxOutputWidth);
|
|
||||||
const withOriginal =
|
|
||||||
selected.length > 0 ? selected : [Math.max(1, Math.round(sourceWidth))];
|
|
||||||
|
|
||||||
return [...new Set(withOriginal)].sort((a, b) => a - b);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSource = ({ type, srcset }, sizes) =>
|
|
||||||
`<source type="${type}" srcset="${escapeHtml(srcset)}" sizes="${escapeHtml(sizes)}">`;
|
|
||||||
|
|
||||||
const renderPicture = (
|
|
||||||
photo,
|
|
||||||
sizes,
|
|
||||||
imgAttributes = '',
|
|
||||||
src = photo.fallback.src
|
|
||||||
) => {
|
|
||||||
const [avif, webp, jpeg] = photo.sources;
|
|
||||||
|
|
||||||
return `<picture>
|
|
||||||
${renderSource(avif, sizes)}
|
|
||||||
${renderSource(webp, sizes)}
|
|
||||||
<img src="${src}" srcset="${escapeHtml(jpeg.srcset)}" sizes="${escapeHtml(sizes)}" width="${photo.width}" height="${photo.height}" alt="${escapeHtml(photo.alt)}" ${imgAttributes}>
|
|
||||||
</picture>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderThumbnail = (photo, isCurrent) => {
|
|
||||||
const sizes =
|
|
||||||
photo.orientation === 'landscape'
|
|
||||||
? '(max-width: 899px) 32vmin, 12vmin'
|
|
||||||
: '(max-width: 899px) 18vmin, 8vmin';
|
|
||||||
|
|
||||||
const smallestJpeg = photo.sources.find(
|
|
||||||
(source) => source.type === 'image/jpeg'
|
|
||||||
).variants[0];
|
|
||||||
|
|
||||||
return `<a class="thumbnail thumbnail--${photo.orientation}" href="${photo.fallback.src}" data-photo-id="${photo.id}"${isCurrent ? ' aria-current="true"' : ''}>
|
|
||||||
${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)}
|
|
||||||
</a>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFrame = (photo) => `<figure class="frame-figure">
|
|
||||||
${renderPicture(
|
|
||||||
photo,
|
|
||||||
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)',
|
|
||||||
'id="frame-image" decoding="async" fetchpriority="high"'
|
|
||||||
)}
|
|
||||||
<figcaption id="frame-caption">${escapeHtml(photo.caption)}</figcaption>
|
|
||||||
</figure>`;
|
|
||||||
|
|
||||||
const assertCatalogMatchesFiles = async (catalog) => {
|
|
||||||
const sourceFiles = new Set(
|
|
||||||
(await readdir(sourceDir)).filter((file) =>
|
|
||||||
file.toLowerCase().endsWith('.jpg')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const catalogFiles = new Set(catalog.map((entry) => entry.file));
|
|
||||||
|
|
||||||
for (const file of sourceFiles) {
|
|
||||||
if (!catalogFiles.has(file)) {
|
|
||||||
throw new Error(`Photo catalog is missing ${file}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of catalogFiles) {
|
|
||||||
if (!sourceFiles.has(file)) {
|
|
||||||
throw new Error(`Photo catalog references missing file ${file}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generate = async () => {
|
|
||||||
const catalog = JSON.parse(await readFile(catalogPath, 'utf8')).map(
|
|
||||||
(entry, index) => ({
|
|
||||||
...entry,
|
|
||||||
id: slugify(entry.file),
|
|
||||||
order: index + 1,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await assertCatalogMatchesFiles(catalog);
|
|
||||||
await rm(publicPhotoDir, { recursive: true, force: true });
|
|
||||||
await mkdir(publicPhotoDir, { recursive: true });
|
|
||||||
await mkdir(generatedDir, { recursive: true });
|
|
||||||
|
|
||||||
const photos = [];
|
|
||||||
|
|
||||||
for (const entry of catalog) {
|
|
||||||
const inputPath = path.join(sourceDir, entry.file);
|
|
||||||
const input = await readFile(inputPath);
|
|
||||||
const hash = createHash('sha256').update(input).digest('hex').slice(0, 10);
|
|
||||||
const metadata = await sharp(input).rotate().metadata();
|
|
||||||
|
|
||||||
if (!metadata.width || !metadata.height) {
|
|
||||||
throw new Error(`Could not read dimensions for ${entry.file}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputWidths = uniqueWidthsFor(metadata.width);
|
|
||||||
const sources = [];
|
|
||||||
|
|
||||||
for (const format of formats) {
|
|
||||||
const variants = outputWidths.map((width) => {
|
|
||||||
const height = Math.round((width / metadata.width) * metadata.height);
|
|
||||||
const filename = `${entry.id}-${hash}-${width}.${format.extension}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
outputPath: path.join(publicPhotoDir, filename),
|
|
||||||
src: `static/photos/${filename}`,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
variants.map(async ({ outputPath, width }) => {
|
|
||||||
const pipeline = sharp(input).rotate().resize({
|
|
||||||
width,
|
|
||||||
withoutEnlargement: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (format.extension === 'avif') {
|
|
||||||
await pipeline.avif(format.options).toFile(outputPath);
|
|
||||||
} else if (format.extension === 'webp') {
|
|
||||||
await pipeline.webp(format.options).toFile(outputPath);
|
|
||||||
} else {
|
|
||||||
await pipeline.jpeg(format.options).toFile(outputPath);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
sources.push({
|
|
||||||
type: format.type,
|
|
||||||
variants: variants.map(({ src, width, height }) => ({
|
|
||||||
src,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})),
|
|
||||||
srcset: variants
|
|
||||||
.map((variant) => `${variant.src} ${variant.width}w`)
|
|
||||||
.join(', '),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const jpegSource = sources.find((source) => source.type === 'image/jpeg');
|
|
||||||
const fallback = jpegSource.variants.at(-1);
|
|
||||||
|
|
||||||
photos.push({
|
|
||||||
id: entry.id,
|
|
||||||
file: entry.file,
|
|
||||||
order: entry.order,
|
|
||||||
alt: entry.alt,
|
|
||||||
caption: entry.caption,
|
|
||||||
orientation: metadata.width >= metadata.height ? 'landscape' : 'portrait',
|
|
||||||
width: metadata.width,
|
|
||||||
height: metadata.height,
|
|
||||||
aspectRatio: Number((metadata.width / metadata.height).toFixed(4)),
|
|
||||||
sources,
|
|
||||||
fallback,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const module = `import type { Photo } from '../photo-types';
|
|
||||||
|
|
||||||
export const photos = ${JSON.stringify(photos, null, 2)} as const satisfies readonly Photo[];
|
|
||||||
`;
|
|
||||||
|
|
||||||
const firstPhoto = photos[0];
|
|
||||||
const portraits = photos
|
|
||||||
.filter((photo) => photo.orientation === 'portrait')
|
|
||||||
.map((photo) => renderThumbnail(photo, photo.id === firstPhoto.id))
|
|
||||||
.join('\n');
|
|
||||||
const landscapes = photos
|
|
||||||
.filter((photo) => photo.orientation === 'landscape')
|
|
||||||
.map((photo) => renderThumbnail(photo, photo.id === firstPhoto.id))
|
|
||||||
.join('\n');
|
|
||||||
const galleryHtml = `<!-- portraits -->
|
|
||||||
${portraits}
|
|
||||||
<!-- /portraits -->
|
|
||||||
<!-- frame -->
|
|
||||||
${renderFrame(firstPhoto)}
|
|
||||||
<!-- /frame -->
|
|
||||||
<!-- landscapes -->
|
|
||||||
${landscapes}
|
|
||||||
<!-- /landscapes -->
|
|
||||||
`;
|
|
||||||
|
|
||||||
await writeFile(path.join(generatedDir, 'photos.ts'), module);
|
|
||||||
await writeFile(path.join(generatedDir, 'gallery.html'), galleryHtml);
|
|
||||||
console.log(
|
|
||||||
`Generated ${photos.length} photos and ${photos.reduce(
|
|
||||||
(count, photo) =>
|
|
||||||
count +
|
|
||||||
photo.sources.reduce(
|
|
||||||
(sourceCount, source) => sourceCount + source.variants.length,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
0
|
|
||||||
)} responsive assets.`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
await generate();
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { readdir, rename, rm } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const rootDir = path.resolve(__dirname, '..');
|
|
||||||
const pictureDir = path.join(rootDir, 'src/pictures');
|
|
||||||
|
|
||||||
const files = (await readdir(pictureDir)).filter((file) =>
|
|
||||||
file.toLowerCase().endsWith('.jpg')
|
|
||||||
);
|
|
||||||
|
|
||||||
let stripped = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const sourcePath = path.join(pictureDir, file);
|
|
||||||
const metadata = await sharp(sourcePath).metadata();
|
|
||||||
|
|
||||||
if (!metadata.exif && !metadata.xmp && !metadata.iptc) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempPath = `${sourcePath}.stripped`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sharp(sourcePath)
|
|
||||||
.rotate()
|
|
||||||
.jpeg({ quality: 95, mozjpeg: true, progressive: true })
|
|
||||||
.toFile(tempPath);
|
|
||||||
await rename(tempPath, sourcePath);
|
|
||||||
stripped += 1;
|
|
||||||
} catch (error) {
|
|
||||||
await rm(tempPath, { force: true });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Stripped metadata from ${stripped} source photos.`);
|
|
||||||
22
src/helper/create-image.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { ResponsiveImage } from '../model/responsive-image';
|
||||||
|
import { last } from './last';
|
||||||
|
|
||||||
|
export const createImage = (
|
||||||
|
image: ResponsiveImage,
|
||||||
|
alt: string,
|
||||||
|
tabIndex: number,
|
||||||
|
imageScreenRatio = 0.25
|
||||||
|
): HTMLImageElement => {
|
||||||
|
const img = new Image(image.width, image.height);
|
||||||
|
|
||||||
|
img.tabIndex = tabIndex;
|
||||||
|
img.srcset = image.srcSet;
|
||||||
|
img.sizes =
|
||||||
|
image.images
|
||||||
|
.map(d => `(max-width: ${d.width / imageScreenRatio}px) ${d.width}px,`)
|
||||||
|
.join('\n') + `\n${last(image.images).width}px`;
|
||||||
|
img.src = last(image.images)?.path;
|
||||||
|
img.alt = alt;
|
||||||
|
|
||||||
|
return img;
|
||||||
|
};
|
||||||
2
src/helper/last.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const last = <T>(list: ArrayLike<T>): T =>
|
||||||
|
list.length >= 1 ? list[list.length - 1] : undefined;
|
||||||
100
src/index.html
|
|
@ -1,76 +1,24 @@
|
||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<title>Photos - András Schmelczer</title>
|
||||||
<title>Photos - András Schmelczer</title>
|
<meta name="description" content="A very simple page where I showcase some of my photos. Although I am not a professional photographer, I truly love capturing joyous moments.">
|
||||||
<meta
|
<meta name="theme-color" content="#A63446">
|
||||||
name="description"
|
<meta name="viewport" content="initial-scale=1.0" />
|
||||||
content="A small photo portfolio by András Schmelczer, with portraits, landscapes, city scenes, and quiet details."
|
|
||||||
/>
|
<link href="https://fonts.googleapis.com/css?family=Roboto:100" rel="stylesheet">
|
||||||
<meta name="theme-color" content="#a63446" />
|
</head>
|
||||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
<body>
|
||||||
<link rel="canonical" href="__SITE_URL__" />
|
<header>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
<h1>András Schmelczer</h1>
|
||||||
<link rel="icon" href="favicon.ico" sizes="any" />
|
</header>
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
|
<main id="images">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
|
<section id="portraits" class="cluster"></section>
|
||||||
<link rel="manifest" href="site.webmanifest" />
|
<div id="frame-container">
|
||||||
|
<img src="" alt="opened photo" id="frame-image"/>
|
||||||
<meta property="og:type" content="website" />
|
</div>
|
||||||
<meta property="og:url" content="__SITE_URL__" />
|
<section id="landscapes" class="cluster"></section>
|
||||||
<meta property="og:title" content="Photos - András Schmelczer" />
|
</main>
|
||||||
<meta
|
</body>
|
||||||
property="og:description"
|
</html>
|
||||||
content="Portraits, landscapes, city scenes, and quiet details photographed by András Schmelczer."
|
|
||||||
/>
|
|
||||||
<meta property="og:image" content="__OG_IMAGE_URL__" />
|
|
||||||
<meta property="og:image:alt" content="Photo portfolio preview" />
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:title" content="Photos - András Schmelczer" />
|
|
||||||
<meta
|
|
||||||
name="twitter:description"
|
|
||||||
content="Portraits, landscapes, city scenes, and quiet details photographed by András Schmelczer."
|
|
||||||
/>
|
|
||||||
<meta name="twitter:image" content="__OG_IMAGE_URL__" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="site-header">
|
|
||||||
<button
|
|
||||||
id="slideshow-toggle"
|
|
||||||
class="slideshow-toggle"
|
|
||||||
type="button"
|
|
||||||
aria-pressed="false"
|
|
||||||
>
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
<h1>András Schmelczer</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="gallery" class="gallery" aria-label="Photo gallery">
|
|
||||||
<section
|
|
||||||
id="portraits"
|
|
||||||
class="rail rail--portraits"
|
|
||||||
aria-label="Portrait photos"
|
|
||||||
>
|
|
||||||
<!-- gallery:portraits -->
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="frame" aria-live="polite">
|
|
||||||
<div id="frame-content" class="frame-content">
|
|
||||||
<!-- gallery:frame -->
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
id="landscapes"
|
|
||||||
class="rail rail--landscapes"
|
|
||||||
aria-label="Landscape photos"
|
|
||||||
>
|
|
||||||
<!-- gallery:landscapes -->
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script type="module" src="/index.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
|
||||||
464
src/index.scss
|
|
@ -1,287 +1,239 @@
|
||||||
$breakpoint: 900px;
|
@mixin center-children() {
|
||||||
|
display: flex;
|
||||||
:root {
|
justify-content: center;
|
||||||
--color-text: #fff;
|
align-items: center;
|
||||||
--color-ink: #202124;
|
|
||||||
--color-accent: #a63446;
|
|
||||||
--color-accent-strong: #7c2130;
|
|
||||||
--color-background: #eee;
|
|
||||||
--color-focus: #ffd166;
|
|
||||||
--border-width: 5px;
|
|
||||||
--space-xs: 0.375rem;
|
|
||||||
--space-s: 0.75rem;
|
|
||||||
--space-m: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin portrait() {
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin landscape() {
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$text-color: #ffffff;
|
||||||
|
$accent-color: #a63446;
|
||||||
|
$background-color: #eeeeee;
|
||||||
|
$border-width: 5px;
|
||||||
|
$normal-margin: 24px;
|
||||||
|
$small-margin: 6px;
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
min-height: 100%;
|
|
||||||
background: var(--color-background);
|
|
||||||
color: var(--color-ink);
|
|
||||||
font-family:
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100dvh;
|
|
||||||
block-size: 100dvh;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
padding: 0;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
max-inline-size: 100%;
|
|
||||||
block-size: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus {
|
:focus {
|
||||||
outline: none;
|
outline: $accent-color solid $border-width * 1.1;
|
||||||
|
outline-offset: -$border-width * 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
img {
|
||||||
outline: 3px solid var(--color-focus);
|
border: $border-width solid $text-color;
|
||||||
outline-offset: 4px;
|
box-sizing: content-box;
|
||||||
}
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
.site-header {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
position: relative;
|
|
||||||
min-inline-size: 5rem;
|
|
||||||
background: var(--color-accent);
|
|
||||||
color: var(--color-text);
|
|
||||||
padding: var(--space-m) var(--space-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: clamp(1.5rem, 4vmin, 3rem);
|
|
||||||
font-weight: 300;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 1;
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 100dvh;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(5rem, 10vmin) minmax(0, 1fr) minmax(
|
|
||||||
7rem,
|
|
||||||
14vmin
|
|
||||||
);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail {
|
|
||||||
min-height: 0;
|
|
||||||
max-height: 100dvh;
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-s);
|
|
||||||
padding: var(--space-m) var(--space-s);
|
|
||||||
scrollbar-color: var(--color-accent) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail {
|
|
||||||
display: block;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border: var(--border-width) solid var(--color-text);
|
|
||||||
background: var(--color-text);
|
|
||||||
color: inherit;
|
|
||||||
box-shadow: 0 0 0 1px rgb(32 33 36 / 16%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail:hover,
|
|
||||||
.thumbnail.is-selected {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail:focus-visible {
|
|
||||||
outline: 3px solid var(--color-focus);
|
|
||||||
outline-offset: 3px;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 6px var(--color-accent-strong),
|
|
||||||
0 0 0 1px rgb(32 33 36 / 16%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail--portrait {
|
|
||||||
inline-size: 8vmin;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail--landscape {
|
|
||||||
inline-size: 12vmin;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail picture,
|
|
||||||
.thumbnail img {
|
|
||||||
inline-size: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
|
||||||
padding: var(--space-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame-content {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame-figure {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: grid;
|
|
||||||
justify-items: center;
|
|
||||||
gap: var(--space-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame-figure picture {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame-figure img {
|
|
||||||
inline-size: auto;
|
|
||||||
max-inline-size: 100%;
|
|
||||||
max-block-size: calc(100dvh - 5rem);
|
|
||||||
border: var(--border-width) solid var(--color-text);
|
|
||||||
background: var(--color-text);
|
|
||||||
object-fit: contain;
|
|
||||||
box-shadow: 0 0 0 1px rgb(32 33 36 / 16%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame-figure figcaption {
|
|
||||||
max-inline-size: min(42rem, 100%);
|
|
||||||
color: var(--color-ink);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slideshow-toggle {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
inset-block-start: var(--space-s);
|
|
||||||
inset-inline-start: var(--space-s);
|
|
||||||
justify-self: start;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.45rem 0.75rem;
|
|
||||||
background: var(--color-accent-strong);
|
|
||||||
color: var(--color-text);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.js .slideshow-toggle {
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slideshow-toggle:hover {
|
html {
|
||||||
background: #5f1724;
|
height: 100%;
|
||||||
}
|
overflow-x: hidden;
|
||||||
|
background-color: $background-color;
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width < $breakpoint) {
|
|
||||||
body {
|
body {
|
||||||
min-height: 100dvh;
|
display: flex;
|
||||||
display: grid;
|
overflow: hidden;
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-header {
|
@include portrait {
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
height: 100%;
|
||||||
column-gap: var(--space-s);
|
flex-direction: column;
|
||||||
padding: var(--space-s);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.slideshow-toggle {
|
@include landscape {
|
||||||
position: static;
|
&::-webkit-scrollbar-track,
|
||||||
}
|
&::-webkit-scrollbar {
|
||||||
|
background-color: transparent;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: $accent-color;
|
||||||
|
border-radius: 1000px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h1,
|
||||||
|
header {
|
||||||
|
font-family: 'Roboto', serif;
|
||||||
|
font-weight: 100;
|
||||||
|
|
||||||
.site-header h1 {
|
@include landscape {
|
||||||
justify-self: center;
|
font-size: 3rem;
|
||||||
writing-mode: horizontal-tb;
|
}
|
||||||
transform: none;
|
|
||||||
font-size: clamp(1.5rem, 7vw, 2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery {
|
@include portrait {
|
||||||
min-height: 0;
|
font-size: 2rem;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
}
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.rail {
|
header {
|
||||||
max-height: none;
|
@include center-children;
|
||||||
min-width: 0;
|
box-sizing: content-box;
|
||||||
overflow: auto hidden;
|
height: 100%;
|
||||||
flex-direction: row;
|
position: relative;
|
||||||
justify-content: flex-start;
|
|
||||||
padding: var(--space-xs) var(--space-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail--portraits {
|
@include portrait {
|
||||||
grid-row: 1;
|
width: 100%;
|
||||||
}
|
height: 2.15ch;
|
||||||
|
padding: $normal-margin 0;
|
||||||
|
}
|
||||||
|
|
||||||
.rail--landscapes {
|
@include landscape {
|
||||||
grid-row: 3;
|
width: 2.15ch;
|
||||||
}
|
height: 100vh;
|
||||||
|
padding: 0 $normal-margin;
|
||||||
|
}
|
||||||
|
|
||||||
.thumbnail {
|
h1 {
|
||||||
inline-size: auto;
|
color: $text-color;
|
||||||
}
|
background-color: $accent-color;
|
||||||
|
padding: $normal-margin 0;
|
||||||
|
|
||||||
.thumbnail--portrait img {
|
white-space: nowrap;
|
||||||
block-size: 12vmin;
|
user-select: none;
|
||||||
inline-size: auto;
|
z-index: 2;
|
||||||
}
|
position: fixed;
|
||||||
|
|
||||||
.thumbnail--landscape img {
|
text-align: center;
|
||||||
block-size: 8vmin;
|
|
||||||
inline-size: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame {
|
@include portrait {
|
||||||
grid-row: 2;
|
width: 100%;
|
||||||
padding: var(--space-s);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.frame-figure img {
|
@include landscape {
|
||||||
max-block-size: calc(100dvh - 12rem);
|
width: 101vh;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main#images {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
@include portrait {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
img {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&#portraits > img {
|
||||||
|
@include portrait {
|
||||||
|
height: 12vmin;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
@include landscape {
|
||||||
|
width: 8vmin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&#landscapes > img {
|
||||||
|
@include portrait {
|
||||||
|
height: 8vmin;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
@include landscape {
|
||||||
|
width: 12vmin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
@include portrait {
|
||||||
|
margin: 0 $small-margin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include portrait {
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
margin: $small-margin 0;
|
||||||
|
padding: $small-margin 0;
|
||||||
|
object-fit: contain;
|
||||||
|
|
||||||
|
:first-child {
|
||||||
|
margin-left: 2 * $small-margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 2 * $small-margin !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include landscape {
|
||||||
|
flex-direction: column;
|
||||||
|
margin: $normal-margin;
|
||||||
|
|
||||||
|
img {
|
||||||
|
margin: $small-margin 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#frame-container {
|
||||||
|
@include center-children;
|
||||||
|
margin: 0 $border-width;
|
||||||
|
|
||||||
|
flex: 1;
|
||||||
|
@include landscape {
|
||||||
|
}
|
||||||
|
|
||||||
|
@include portrait {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) translateX(-50%);
|
||||||
|
|
||||||
|
@include portrait {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100% !important; // undo js
|
||||||
|
max-height: 100%;
|
||||||
|
left: 50% !important; // undo js
|
||||||
|
}
|
||||||
|
|
||||||
|
@include landscape {
|
||||||
|
position: fixed;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
src/index.ts
|
|
@ -1,19 +1,35 @@
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
import { photos } from './generated/photos';
|
import './no-change/404.html';
|
||||||
import { PhotoGallery, requiredElement } from './photos';
|
import './no-change/robots.txt';
|
||||||
|
import { Photos } from './photos';
|
||||||
|
|
||||||
document.documentElement.classList.add('js');
|
// @ts-ignore
|
||||||
|
const images = require.context('./pictures', true, /.jpg$/);
|
||||||
|
const imagePath = name => images(name, true);
|
||||||
|
|
||||||
const gallery = requiredElement<HTMLElement>('#gallery', HTMLElement);
|
const addSupportForTabNavigation = () =>
|
||||||
const frame = requiredElement<HTMLElement>('#frame-content', HTMLElement);
|
(document.onkeydown = e => {
|
||||||
const toggle = requiredElement<HTMLButtonElement>(
|
if (e.key === ' ') {
|
||||||
'#slideshow-toggle',
|
(document.activeElement as HTMLElement)?.click();
|
||||||
HTMLButtonElement
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sizeFrame = () => {
|
||||||
|
const container = document.querySelector('#frame-container') as HTMLElement;
|
||||||
|
const image = container.querySelector('img');
|
||||||
|
|
||||||
|
image.style.maxWidth = container.clientWidth + 'px';
|
||||||
|
image.style.left = container.offsetLeft + container.clientWidth / 2 + 'px';
|
||||||
|
};
|
||||||
|
|
||||||
|
new Photos(
|
||||||
|
images.keys().map(k => imagePath(k)),
|
||||||
|
document.querySelector('#landscapes'),
|
||||||
|
document.querySelector('#portraits'),
|
||||||
|
document.querySelector('#frame-image')
|
||||||
);
|
);
|
||||||
|
|
||||||
new PhotoGallery({
|
addSupportForTabNavigation();
|
||||||
photos,
|
window.addEventListener('resize', sizeFrame);
|
||||||
gallery,
|
sizeFrame();
|
||||||
frame,
|
|
||||||
toggle,
|
|
||||||
});
|
|
||||||
|
|
|
||||||
12
src/model/responsive-image.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export type ResponsiveImage = {
|
||||||
|
srcSet: string;
|
||||||
|
src: string;
|
||||||
|
placeholder: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
images: Array<{
|
||||||
|
path: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
36
src/no-change/404.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
|
||||||
|
<title>Not found</title>
|
||||||
|
<meta name="theme-color" content="#b7455e" />
|
||||||
|
<meta name="viewport" content="initial-scale=1.0" />
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #b7455e;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
font-weight: 100;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>The requested resource cannot be found.</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2
src/no-change/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"file": "_DSC1190-Edit.jpg",
|
|
||||||
"alt": "Portrait of a man in a burgundy sleeveless shirt standing with folded arms above a city view.",
|
|
||||||
"caption": "Portrait above the city"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "_DSC1484.jpg",
|
|
||||||
"alt": "A man smiling while drying a plate in a kitchen.",
|
|
||||||
"caption": "Kitchen portrait"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "_DSC1834.jpg",
|
|
||||||
"alt": "A person sitting near dark rocks while waves crash into a sea cave.",
|
|
||||||
"caption": "Watching the surf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "_DSC2313.jpg",
|
|
||||||
"alt": "Portrait of a man in a teal coat standing among autumn leaves.",
|
|
||||||
"caption": "Autumn portrait"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "_DSC2362.jpg",
|
|
||||||
"alt": "Portrait of a man with glasses leaning on a railing in a park.",
|
|
||||||
"caption": "Park portrait"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "_DSC2674.jpg",
|
|
||||||
"alt": "A person in a hooded jacket walking down a narrow city street.",
|
|
||||||
"caption": "City walk"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "_DSC8718-Edit.jpg",
|
|
||||||
"alt": "Defocused city lights glowing across a dark night skyline.",
|
|
||||||
"caption": "Night lights"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "adam.jpg",
|
|
||||||
"alt": "A man standing against a dark background in a black graphic T-shirt.",
|
|
||||||
"caption": "Adam"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "dorka-1.jpg",
|
|
||||||
"alt": "A woman in a white knit hat sitting outdoors on an autumn day.",
|
|
||||||
"caption": "Dorka"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "kezdetazarovonalbol.jpg",
|
|
||||||
"alt": "A small plant stem silhouetted against warm yellow and orange light.",
|
|
||||||
"caption": "Small stem in warm light"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "nap-2.jpg",
|
|
||||||
"alt": "The sun setting behind a mountain ridge over a field.",
|
|
||||||
"caption": "Sunset field"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "nyomtatas-8.jpg",
|
|
||||||
"alt": "Close-up of ice crystals and frozen texture in blue light.",
|
|
||||||
"caption": "Ice crystals"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "nyomtatas-10.jpg",
|
|
||||||
"alt": "Warm abstract light passing through dried plants.",
|
|
||||||
"caption": "Dried plants in light"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-1-2.jpg",
|
|
||||||
"alt": "A city panorama seen through a coin-operated viewing telescope.",
|
|
||||||
"caption": "City lookout"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-1.jpg",
|
|
||||||
"alt": "Black and white stage scene with dancers performing before an audience.",
|
|
||||||
"caption": "Stage performance"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-2-2.jpg",
|
|
||||||
"alt": "Two zebras walking across a black and white landscape.",
|
|
||||||
"caption": "Zebras"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-2.jpg",
|
|
||||||
"alt": "Coastal town and hillside beside a blue sea under a bright sky.",
|
|
||||||
"caption": "Coastal town"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-3-2.jpg",
|
|
||||||
"alt": "A woman walking away on a beach while carrying a bag.",
|
|
||||||
"caption": "Beach walk"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-3.jpg",
|
|
||||||
"alt": "Black and white lake scene with a small boat under a bright sun.",
|
|
||||||
"caption": "Boat on the lake"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-4-2.jpg",
|
|
||||||
"alt": "Black and white seascape with waves rolling toward weathered posts.",
|
|
||||||
"caption": "Sea posts"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-5.jpg",
|
|
||||||
"alt": "Candles glowing in the foreground with soft golden lights behind them.",
|
|
||||||
"caption": "Candles"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-6.jpg",
|
|
||||||
"alt": "Aerial view over a city and river beneath broken clouds.",
|
|
||||||
"caption": "City and river"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-7.jpg",
|
|
||||||
"alt": "The Eiffel Tower lit at night against a dark sky.",
|
|
||||||
"caption": "Eiffel Tower at night"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-8.jpg",
|
|
||||||
"alt": "Bare winter trees fading into a pale snowy background.",
|
|
||||||
"caption": "Winter trees"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-10.jpg",
|
|
||||||
"alt": "A person in a dark jacket sitting on a bench and looking over water.",
|
|
||||||
"caption": "Looking across the water"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-11.jpg",
|
|
||||||
"alt": "Black and white close-up portrait of a dog looking into the camera.",
|
|
||||||
"caption": "Dog portrait"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-12.jpg",
|
|
||||||
"alt": "Two people sitting by water under a vivid orange sunset.",
|
|
||||||
"caption": "Sunset by the water"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-13.jpg",
|
|
||||||
"alt": "Portrait of a woman standing beside a tree in green summer light.",
|
|
||||||
"caption": "Tree portrait"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-14.jpg",
|
|
||||||
"alt": "Golden sunlight reflecting on rippled water.",
|
|
||||||
"caption": "Sunlit ripples"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-17.jpg",
|
|
||||||
"alt": "A small green plant shoot backlit by bright sunlight.",
|
|
||||||
"caption": "Backlit sprout"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-18.jpg",
|
|
||||||
"alt": "Birds flying around pale stone sculpture details.",
|
|
||||||
"caption": "Birds and stone"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-19.jpg",
|
|
||||||
"alt": "A bridge at night with blue lights and long yellow light trails.",
|
|
||||||
"caption": "Bridge light trails"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-20.jpg",
|
|
||||||
"alt": "A seated man in a dark coat photographed at night with city lights behind him.",
|
|
||||||
"caption": "Night portrait"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-22.jpg",
|
|
||||||
"alt": "Silhouettes seen through large windows during a pink and blue sunset.",
|
|
||||||
"caption": "Window silhouettes"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-23.jpg",
|
|
||||||
"alt": "Tall clock tower and stone architecture with a bird flying nearby.",
|
|
||||||
"caption": "Clock tower"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-24.jpg",
|
|
||||||
"alt": "Portrait of a woman at night with bridge lights behind her.",
|
|
||||||
"caption": "Bridge portrait"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-25.jpg",
|
|
||||||
"alt": "Portrait of a smiling man at night with blurred city lights behind him.",
|
|
||||||
"caption": "Smiling night portrait"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-26.jpg",
|
|
||||||
"alt": "Tall bridge lamps receding into a pale sky.",
|
|
||||||
"caption": "Bridge lamps"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "web-42.jpg",
|
|
||||||
"alt": "Warm sunset over a grassy field with low hills in the distance.",
|
|
||||||
"caption": "Sunset hills"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
export type PhotoOrientation = 'landscape' | 'portrait';
|
|
||||||
export type ImageMimeType = 'image/avif' | 'image/webp' | 'image/jpeg';
|
|
||||||
|
|
||||||
export type ImageVariant = {
|
|
||||||
readonly src: string;
|
|
||||||
readonly width: number;
|
|
||||||
readonly height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImageSource = {
|
|
||||||
readonly type: ImageMimeType;
|
|
||||||
readonly variants: readonly ImageVariant[];
|
|
||||||
readonly srcset: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Photo = {
|
|
||||||
readonly id: string;
|
|
||||||
readonly file: string;
|
|
||||||
readonly order: number;
|
|
||||||
readonly alt: string;
|
|
||||||
readonly caption: string;
|
|
||||||
readonly orientation: PhotoOrientation;
|
|
||||||
readonly width: number;
|
|
||||||
readonly height: number;
|
|
||||||
readonly aspectRatio: number;
|
|
||||||
readonly sources: readonly ImageSource[];
|
|
||||||
readonly fallback: ImageVariant;
|
|
||||||
};
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { PhotoGallery, wrapIndex } from './photos';
|
|
||||||
import type { Photo } from './photo-types';
|
|
||||||
|
|
||||||
const variant = (name: string) => ({
|
|
||||||
src: `static/photos/${name}.jpg`,
|
|
||||||
width: 320,
|
|
||||||
height: 240,
|
|
||||||
});
|
|
||||||
|
|
||||||
const photo = (id: string, alt: string): Photo => ({
|
|
||||||
id,
|
|
||||||
file: `${id}.jpg`,
|
|
||||||
order: 1,
|
|
||||||
alt,
|
|
||||||
caption: alt,
|
|
||||||
orientation: 'landscape',
|
|
||||||
width: 320,
|
|
||||||
height: 240,
|
|
||||||
aspectRatio: 1.3333,
|
|
||||||
sources: [
|
|
||||||
{
|
|
||||||
type: 'image/avif',
|
|
||||||
variants: [variant(`${id}.avif`)],
|
|
||||||
srcset: `static/photos/${id}.avif 320w`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image/webp',
|
|
||||||
variants: [variant(`${id}.webp`)],
|
|
||||||
srcset: `static/photos/${id}.webp 320w`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image/jpeg',
|
|
||||||
variants: [variant(`${id}.jpg`)],
|
|
||||||
srcset: `static/photos/${id}.jpg 320w`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
fallback: variant(`${id}.jpg`),
|
|
||||||
});
|
|
||||||
|
|
||||||
const setup = () => {
|
|
||||||
vi.stubGlobal('CSS', {
|
|
||||||
escape: (value: string) => value,
|
|
||||||
});
|
|
||||||
vi.stubGlobal('matchMedia', () => ({
|
|
||||||
matches: false,
|
|
||||||
media: '(prefers-reduced-motion: reduce)',
|
|
||||||
onchange: null,
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
}));
|
|
||||||
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
|
||||||
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<main id="gallery">
|
|
||||||
<a href="static/photos/one.jpg" data-photo-id="one">One</a>
|
|
||||||
<a href="static/photos/two.jpg" data-photo-id="two">Two</a>
|
|
||||||
</main>
|
|
||||||
<section id="frame"></section>
|
|
||||||
<button id="toggle" type="button">Play</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const gallery = document.querySelector<HTMLElement>('#gallery');
|
|
||||||
const frame = document.querySelector<HTMLElement>('#frame');
|
|
||||||
const toggle = document.querySelector<HTMLButtonElement>('#toggle');
|
|
||||||
|
|
||||||
if (!gallery || !frame || !toggle) {
|
|
||||||
throw new Error('Test DOM failed to initialize.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
gallery,
|
|
||||||
frame,
|
|
||||||
toggle,
|
|
||||||
photos: [photo('one', 'First photo'), photo('two', 'Second photo')],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('wrapIndex', () => {
|
|
||||||
it('wraps backward to the last item', () => {
|
|
||||||
expect(wrapIndex(-1, 4)).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps forward to the first item', () => {
|
|
||||||
expect(wrapIndex(4, 4)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty collections', () => {
|
|
||||||
expect(() => wrapIndex(0, 0)).toThrow('without items');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PhotoGallery', () => {
|
|
||||||
it('updates the frame and selected state when a thumbnail is clicked', () => {
|
|
||||||
const { gallery, frame, toggle, photos } = setup();
|
|
||||||
new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false });
|
|
||||||
|
|
||||||
gallery
|
|
||||||
.querySelector<HTMLAnchorElement>('[data-photo-id="two"]')
|
|
||||||
?.dispatchEvent(
|
|
||||||
new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(frame.querySelector('img')?.alt).toBe('Second photo');
|
|
||||||
expect(
|
|
||||||
gallery
|
|
||||||
.querySelector('[data-photo-id="two"]')
|
|
||||||
?.getAttribute('aria-current')
|
|
||||||
).toBe('true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses scoped arrow-key navigation', () => {
|
|
||||||
const { gallery, frame, toggle, photos } = setup();
|
|
||||||
new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false });
|
|
||||||
|
|
||||||
gallery.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', {
|
|
||||||
key: 'ArrowLeft',
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(frame.querySelector('img')?.alt).toBe('Second photo');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
326
src/photos.ts
|
|
@ -1,273 +1,109 @@
|
||||||
import type { ImageSource, Photo } from './photo-types';
|
import { ResponsiveImage } from './model/responsive-image';
|
||||||
|
import { createImage } from './helper/create-image';
|
||||||
|
|
||||||
const AUTO_ADVANCE_DELAY_MS = 7000;
|
export class Photos {
|
||||||
const FRAME_SIZES =
|
private readonly images: Array<HTMLImageElement>;
|
||||||
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)';
|
private _selectedId: number;
|
||||||
|
private timerId: NodeJS.Timeout = null;
|
||||||
|
private shouldNotChange = false;
|
||||||
|
|
||||||
type GalleryOptions = {
|
public constructor(
|
||||||
readonly photos: readonly Photo[];
|
images: Array<ResponsiveImage>,
|
||||||
readonly gallery: HTMLElement;
|
private readonly landscapesContainer: HTMLElement,
|
||||||
readonly frame: HTMLElement;
|
private readonly portraitsContainer: HTMLElement,
|
||||||
readonly toggle: HTMLButtonElement;
|
private readonly pictureFrame: HTMLImageElement
|
||||||
readonly autoAdvance?: boolean;
|
) {
|
||||||
};
|
this.images = this.convertImages(images);
|
||||||
|
this.placeImages();
|
||||||
type SelectOptions = {
|
|
||||||
readonly focusThumbnail?: boolean;
|
|
||||||
readonly pause?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requiredElement = <T extends Element>(
|
|
||||||
selector: string,
|
|
||||||
constructor: { new (...args: never[]): T }
|
|
||||||
): T => {
|
|
||||||
const element = document.querySelector(selector);
|
|
||||||
|
|
||||||
if (!(element instanceof constructor)) {
|
|
||||||
throw new Error(`Required element not found: ${selector}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const wrapIndex = (value: number, count: number): number => {
|
|
||||||
if (count <= 0) {
|
|
||||||
throw new Error('Cannot wrap an index without items.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return ((value % count) + count) % count;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createSource = (source: ImageSource): HTMLSourceElement => {
|
|
||||||
const element = document.createElement('source');
|
|
||||||
element.type = source.type;
|
|
||||||
element.srcset = source.srcset;
|
|
||||||
element.sizes = FRAME_SIZES;
|
|
||||||
|
|
||||||
return element;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFrameFigure = (photo: Photo): HTMLElement => {
|
|
||||||
const figure = document.createElement('figure');
|
|
||||||
figure.className = 'frame-figure';
|
|
||||||
|
|
||||||
const picture = document.createElement('picture');
|
|
||||||
|
|
||||||
for (const source of photo.sources.filter(
|
|
||||||
({ type }) => type !== 'image/jpeg'
|
|
||||||
)) {
|
|
||||||
picture.append(createSource(source));
|
|
||||||
}
|
|
||||||
|
|
||||||
const jpeg = photo.sources.find(({ type }) => type === 'image/jpeg');
|
|
||||||
const image = document.createElement('img');
|
|
||||||
image.id = 'frame-image';
|
|
||||||
image.src = photo.fallback.src;
|
|
||||||
image.srcset = jpeg?.srcset ?? '';
|
|
||||||
image.sizes = FRAME_SIZES;
|
|
||||||
image.width = photo.width;
|
|
||||||
image.height = photo.height;
|
|
||||||
image.alt = photo.alt;
|
|
||||||
image.decoding = 'async';
|
|
||||||
image.fetchPriority = 'high';
|
|
||||||
|
|
||||||
picture.append(image);
|
|
||||||
|
|
||||||
const caption = document.createElement('figcaption');
|
|
||||||
caption.id = 'frame-caption';
|
|
||||||
caption.textContent = photo.caption;
|
|
||||||
|
|
||||||
figure.append(picture, caption);
|
|
||||||
|
|
||||||
return figure;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class PhotoGallery {
|
|
||||||
private selectedIndex = 0;
|
|
||||||
private timerId: number | null = null;
|
|
||||||
private readonly thumbnails: HTMLAnchorElement[];
|
|
||||||
private readonly reducedMotion = window.matchMedia(
|
|
||||||
'(prefers-reduced-motion: reduce)'
|
|
||||||
);
|
|
||||||
|
|
||||||
public constructor(private readonly options: GalleryOptions) {
|
|
||||||
if (options.photos.length === 0) {
|
|
||||||
throw new Error('The gallery needs at least one photo.');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.thumbnails = this.options.photos.map((photo) =>
|
|
||||||
this.thumbnailFor(photo)
|
|
||||||
);
|
|
||||||
this.addEventListeners();
|
this.addEventListeners();
|
||||||
this.select(0);
|
this.startAutoChange();
|
||||||
|
this.selectedId = 0;
|
||||||
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
|
|
||||||
this.startAutoAdvance();
|
|
||||||
} else {
|
|
||||||
this.updateToggle(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private addEventListeners(): void {
|
private get selectedId(): number {
|
||||||
this.options.gallery.addEventListener('click', (event) => {
|
return this._selectedId;
|
||||||
const thumbnail = (event.target as Element).closest<HTMLAnchorElement>(
|
}
|
||||||
'[data-photo-id]'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!thumbnail) {
|
private set selectedId(value: number) {
|
||||||
return;
|
if (value < 0) {
|
||||||
|
value += this.images.length - 1;
|
||||||
|
}
|
||||||
|
this._selectedId = value % this.images.length;
|
||||||
|
this.loadToFrame(this.images[this.selectedId]);
|
||||||
|
this.images[this.selectedId].focus();
|
||||||
|
this.shouldNotChange = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertImages(images: Array<ResponsiveImage>) {
|
||||||
|
let portraits = 0; // evens
|
||||||
|
let landscapes = 1; // odds
|
||||||
|
|
||||||
|
const imgs = images.map((image, i) =>
|
||||||
|
createImage(
|
||||||
|
image,
|
||||||
|
'a photo',
|
||||||
|
Photos.isLandscape(image) ? (landscapes += 2) : (portraits += 2)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return imgs.sort((a, b) => a.tabIndex - b.tabIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeImages() {
|
||||||
|
this.images.forEach(img => {
|
||||||
|
if (Photos.isLandscape(img)) {
|
||||||
|
img.classList.add('landscape');
|
||||||
|
this.landscapesContainer.appendChild(img);
|
||||||
|
} else {
|
||||||
|
img.classList.add('portrait');
|
||||||
|
this.portraitsContainer.appendChild(img);
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
this.selectById(thumbnail.dataset.photoId, { pause: true });
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.options.gallery.addEventListener('keydown', (event) => {
|
private addEventListeners() {
|
||||||
if (!this.options.gallery.contains(event.target as Node)) {
|
window.document.addEventListener('keydown', e => {
|
||||||
return;
|
switch (e.key) {
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
event.preventDefault();
|
this.selectedId--;
|
||||||
this.select(this.selectedIndex - 1, {
|
|
||||||
focusThumbnail: true,
|
|
||||||
pause: true,
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
event.preventDefault();
|
this.selectedId++;
|
||||||
this.select(this.selectedIndex + 1, {
|
|
||||||
focusThumbnail: true,
|
|
||||||
pause: true,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
event.preventDefault();
|
|
||||||
this.select(0, { focusThumbnail: true, pause: true });
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
event.preventDefault();
|
|
||||||
this.select(this.options.photos.length - 1, {
|
|
||||||
focusThumbnail: true,
|
|
||||||
pause: true,
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
if (this.timerId !== null) {
|
||||||
|
clearInterval(this.timerId);
|
||||||
|
this.timerId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.options.gallery.addEventListener('pointerdown', () =>
|
window.document.addEventListener(
|
||||||
this.pauseAutoAdvance()
|
'scroll',
|
||||||
);
|
() => (this.shouldNotChange = true)
|
||||||
this.options.gallery.addEventListener(
|
|
||||||
'wheel',
|
|
||||||
() => this.pauseAutoAdvance(),
|
|
||||||
{ passive: true }
|
|
||||||
);
|
|
||||||
this.options.gallery.addEventListener(
|
|
||||||
'touchstart',
|
|
||||||
() => this.pauseAutoAdvance(),
|
|
||||||
{ passive: true }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.options.toggle.addEventListener('click', () => {
|
this.images.forEach((image, i) =>
|
||||||
if (this.timerId === null) {
|
image.addEventListener('click', () => (this.selectedId = i))
|
||||||
this.startAutoAdvance();
|
|
||||||
} else {
|
|
||||||
this.pauseAutoAdvance();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.reducedMotion.addEventListener('change', () => {
|
|
||||||
if (this.reducedMotion.matches) {
|
|
||||||
this.pauseAutoAdvance();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private thumbnailFor(photo: Photo): HTMLAnchorElement {
|
|
||||||
const thumbnail = this.options.gallery.querySelector<HTMLAnchorElement>(
|
|
||||||
`[data-photo-id="${CSS.escape(photo.id)}"]`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!thumbnail) {
|
|
||||||
throw new Error(`Missing thumbnail for ${photo.id}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return thumbnail;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectById(id: string | undefined, options: SelectOptions): void {
|
private startAutoChange() {
|
||||||
const index = this.options.photos.findIndex((photo) => photo.id === id);
|
this.timerId = setInterval(() => {
|
||||||
|
if (!this.shouldNotChange) {
|
||||||
if (index >= 0) {
|
this.selectedId++;
|
||||||
this.select(index, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private select(value: number, options: SelectOptions = {}): void {
|
|
||||||
this.selectedIndex = wrapIndex(value, this.options.photos.length);
|
|
||||||
const photo = this.options.photos[this.selectedIndex];
|
|
||||||
|
|
||||||
if (!photo) {
|
|
||||||
throw new Error('Selected photo is missing.');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.options.frame.replaceChildren(createFrameFigure(photo));
|
|
||||||
this.updateSelectedThumbnail(photo, options.focusThumbnail ?? false);
|
|
||||||
|
|
||||||
if (options.pause) {
|
|
||||||
this.pauseAutoAdvance();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateSelectedThumbnail(photo: Photo, shouldFocus: boolean): void {
|
|
||||||
for (const thumbnail of this.thumbnails) {
|
|
||||||
const selected = thumbnail.dataset.photoId === photo.id;
|
|
||||||
if (selected) {
|
|
||||||
thumbnail.setAttribute('aria-current', 'true');
|
|
||||||
} else {
|
|
||||||
thumbnail.removeAttribute('aria-current');
|
|
||||||
}
|
}
|
||||||
thumbnail.classList.toggle('is-selected', selected);
|
this.shouldNotChange = false;
|
||||||
|
}, 7000);
|
||||||
if (selected && shouldFocus) {
|
|
||||||
thumbnail.focus({ preventScroll: true });
|
|
||||||
thumbnail.scrollIntoView({
|
|
||||||
block: 'nearest',
|
|
||||||
inline: 'nearest',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private startAutoAdvance(): void {
|
private loadToFrame(img: HTMLImageElement) {
|
||||||
this.pauseAutoAdvance(false);
|
this.pictureFrame.src = img.src;
|
||||||
|
|
||||||
if (this.reducedMotion.matches) {
|
|
||||||
this.updateToggle(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.timerId = window.setInterval(() => {
|
|
||||||
this.select(this.selectedIndex + 1);
|
|
||||||
}, AUTO_ADVANCE_DELAY_MS);
|
|
||||||
this.updateToggle(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private pauseAutoAdvance(updateToggle = true): void {
|
private static isLandscape(
|
||||||
if (this.timerId !== null) {
|
image: HTMLImageElement | ResponsiveImage
|
||||||
window.clearInterval(this.timerId);
|
): boolean {
|
||||||
this.timerId = null;
|
return image.width > image.height;
|
||||||
}
|
|
||||||
|
|
||||||
if (updateToggle) {
|
|
||||||
this.updateToggle(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateToggle(isRunning: boolean): void {
|
|
||||||
this.options.toggle.setAttribute('aria-pressed', String(isRunning));
|
|
||||||
this.options.toggle.textContent = isRunning ? 'Pause' : 'Play';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 3 MiB After Width: | Height: | Size: 3 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 7.4 MiB After Width: | Height: | Size: 9.6 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 6.7 MiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 2 MiB After Width: | Height: | Size: 6.5 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 7.6 MiB |
|
Before Width: | Height: | Size: 482 KiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 429 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 437 KiB After Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 820 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 572 KiB After Width: | Height: | Size: 465 KiB |
|
Before Width: | Height: | Size: 691 KiB After Width: | Height: | Size: 670 KiB |
|
Before Width: | Height: | Size: 920 KiB After Width: | Height: | Size: 810 KiB |
|
|
@ -1,9 +0,0 @@
|
||||||
export default {
|
|
||||||
extends: ['stylelint-config-standard-scss'],
|
|
||||||
rules: {
|
|
||||||
'selector-id-pattern': null,
|
|
||||||
'selector-class-pattern':
|
|
||||||
'^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*)?$',
|
|
||||||
'scss/dollar-variable-pattern': null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,26 +1,11 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"outDir": "./dist/",
|
||||||
"useDefineForClassFields": true,
|
"noImplicitAny": false,
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
"module": "es6",
|
||||||
"allowJs": false,
|
"target": "es5",
|
||||||
"skipLibCheck": true,
|
"sourceMap": true,
|
||||||
"esModuleInterop": true,
|
"allowJs": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"downlevelIteration": true
|
||||||
"strict": true,
|
}
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"types": ["vite/client", "vitest/globals"]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.json",
|
|
||||||
"vite.config.ts",
|
|
||||||
"eslint.config.js"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import { defineConfig, type Plugin } from 'vite';
|
|
||||||
|
|
||||||
const SITE_URL = 'https://schmelczer.dev/photos/';
|
|
||||||
|
|
||||||
const readGallerySection = (name: string): string => {
|
|
||||||
try {
|
|
||||||
const html = readFileSync(
|
|
||||||
resolve(__dirname, 'src/generated/gallery.html'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
const match = html.match(
|
|
||||||
new RegExp(`<!-- ${name} -->([\\s\\S]*?)<!-- /${name} -->`)
|
|
||||||
);
|
|
||||||
|
|
||||||
return match?.[1]?.trim() ?? '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generatedGalleryPlugin = (): Plugin => ({
|
|
||||||
name: 'generated-gallery',
|
|
||||||
transformIndexHtml(html) {
|
|
||||||
const ogImageUrl = new URL('og-image.jpg', SITE_URL).toString();
|
|
||||||
|
|
||||||
return html
|
|
||||||
.replaceAll('__SITE_URL__', SITE_URL)
|
|
||||||
.replaceAll('__OG_IMAGE_URL__', ogImageUrl)
|
|
||||||
.replace('<!-- gallery:portraits -->', readGallerySection('portraits'))
|
|
||||||
.replace('<!-- gallery:frame -->', readGallerySection('frame'))
|
|
||||||
.replace('<!-- gallery:landscapes -->', readGallerySection('landscapes'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
base: './',
|
|
||||||
root: 'src',
|
|
||||||
publicDir: '../public',
|
|
||||||
build: {
|
|
||||||
outDir: '../dist',
|
|
||||||
emptyOutDir: true,
|
|
||||||
assetsDir: 'assets',
|
|
||||||
sourcemap: false,
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 5173,
|
|
||||||
strictPort: false,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 4173,
|
|
||||||
strictPort: false,
|
|
||||||
},
|
|
||||||
plugins: [generatedGalleryPlugin()],
|
|
||||||
});
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
include: ['src/**/*.test.ts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
174
webpack.config.js
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
const path = require('path');
|
||||||
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
|
||||||
|
const TerserJSPlugin = require('terser-webpack-plugin');
|
||||||
|
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
||||||
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
|
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
|
||||||
|
const Sharp = require('responsive-loader/sharp');
|
||||||
|
const Sass = require('sass');
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
watchOptions: {
|
||||||
|
ignored: /node_modules/,
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
disableHostCheck: true,
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
minimizer: [
|
||||||
|
new TerserJSPlugin({
|
||||||
|
sourceMap: !isProduction,
|
||||||
|
}),
|
||||||
|
new OptimizeCSSAssetsPlugin({}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CleanWebpackPlugin(),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
xhtml: true,
|
||||||
|
template: './src/index.html',
|
||||||
|
minify: {
|
||||||
|
collapseWhitespace: true,
|
||||||
|
removeComments: true,
|
||||||
|
removeRedundantAttributes: true,
|
||||||
|
removeScriptTypeAttributes: true,
|
||||||
|
removeStyleLinkTypeAttributes: true,
|
||||||
|
useShortDoctype: true,
|
||||||
|
},
|
||||||
|
inlineSource: '.(js|css)$',
|
||||||
|
}),
|
||||||
|
new HtmlWebpackInlineSourcePlugin(),
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: '[name].[contenthash].css',
|
||||||
|
chunkFilename: '[id].[contenthash].css',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
entry: {
|
||||||
|
index: './src/index.ts',
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.(jpe?g|png)$/i,
|
||||||
|
loader: 'responsive-loader',
|
||||||
|
options: {
|
||||||
|
adapter: Sharp,
|
||||||
|
quality: 90,
|
||||||
|
outputPath: 'static/',
|
||||||
|
sizes: [300, 800, 1200, 2000],
|
||||||
|
placeholder: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(webm|mp4|gif)$/i,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: 'file-loader',
|
||||||
|
query: {
|
||||||
|
outputPath: 'static/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: 'image-webpack-loader',
|
||||||
|
options: {
|
||||||
|
disable: !isProduction,
|
||||||
|
mozjpeg: {
|
||||||
|
progressive: true,
|
||||||
|
quality: 65,
|
||||||
|
},
|
||||||
|
optipng: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
pngquant: {
|
||||||
|
quality: [0.65, 0.9],
|
||||||
|
speed: 4,
|
||||||
|
},
|
||||||
|
gifsicle: {
|
||||||
|
interlaced: false,
|
||||||
|
},
|
||||||
|
webp: {
|
||||||
|
quality: 65,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.svg$/,
|
||||||
|
loader: 'svg-url-loader',
|
||||||
|
options: {
|
||||||
|
limit: 10 * 1024,
|
||||||
|
noquotes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(pdf)$/i,
|
||||||
|
use: {
|
||||||
|
loader: 'file-loader',
|
||||||
|
query: {
|
||||||
|
outputPath: 'static/',
|
||||||
|
name: '[name].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /no-change.*(ico|html|txt)$/i,
|
||||||
|
use: {
|
||||||
|
loader: 'file-loader',
|
||||||
|
query: {
|
||||||
|
outputPath: '/',
|
||||||
|
name: '[name].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.scss$/i,
|
||||||
|
use: [
|
||||||
|
MiniCssExtractPlugin.loader,
|
||||||
|
'css-loader',
|
||||||
|
'postcss-loader',
|
||||||
|
{
|
||||||
|
loader: 'resolve-url-loader',
|
||||||
|
options: {
|
||||||
|
keepQuery: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: 'sass-loader',
|
||||||
|
options: {
|
||||||
|
sourceMap: true,
|
||||||
|
implementation: Sass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(woff2?|ttf|eot|svg)(?:[?#].+)?$/,
|
||||||
|
use: {
|
||||||
|
loader: 'file-loader',
|
||||||
|
options: {
|
||||||
|
name: '[name].[ext]',
|
||||||
|
outputPath: 'static/fonts/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: /fonts/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.ts$/,
|
||||||
|
use: 'ts-loader',
|
||||||
|
exclude: /node_modules/,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.ts', '.js'],
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
filename: '[name].[contenthash].js',
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
},
|
||||||
|
};
|
||||||