Compare commits
3 commits
ff6c7f6f68
...
5bdf68eabd
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bdf68eabd | |||
| 1914991250 | |||
| 35a962935c |
|
|
@ -7,12 +7,15 @@ on:
|
|||
branches: ['main']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: 'pages'
|
||||
cancel-in-progress: false
|
||||
group: 'photos-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
validate:
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
|
|
@ -21,18 +24,48 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '12'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Lint and static checks
|
||||
run: npm run lint:check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
npm run build
|
||||
run: npm run build
|
||||
|
||||
- name: Copy files to nginx pages mount
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- 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: |
|
||||
apt update && apt install -y rsync
|
||||
rsync -a --delete --mkpath --chmod=ugo=rwx dist/ /pages/photos/
|
||||
|
|
|
|||
1
.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
|||
og-image.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
5
.gitignore
vendored
|
|
@ -1,5 +1,8 @@
|
|||
node_modules
|
||||
dist
|
||||
target
|
||||
package-lock.json
|
||||
.DS_Store
|
||||
public/static/photos
|
||||
src/generated
|
||||
playwright-report
|
||||
test-results
|
||||
|
|
|
|||
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
22.13.0
|
||||
81
README.md
|
|
@ -1 +1,82 @@
|
|||
# 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
|
|
@ -1,26 +0,0 @@
|
|||
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;
|
||||
}
|
||||
21
e2e/gallery.spec.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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 ?? ''
|
||||
);
|
||||
});
|
||||
38
eslint.config.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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: 131 B After Width: | Height: | Size: 243 KiB |
5495
package-lock.json
generated
Normal file
74
package.json
|
|
@ -1,61 +1,55 @@
|
|||
{
|
||||
"name": "portfolio",
|
||||
"name": "photos",
|
||||
"version": "1.0.0",
|
||||
"description": "An easily configurable portfolio.",
|
||||
"description": "A static, progressively enhanced photo portfolio.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server --mode development",
|
||||
"build": "webpack && find dist -type f -regex \".*\\(js\\|css\\|LICENSE.*\\)\" | xargs rm"
|
||||
"dev": "node scripts/generate-assets.mjs && vite --host 0.0.0.0",
|
||||
"lint": "eslint . --fix && stylelint \"src/**/*.scss\" --fix && prettier --write .",
|
||||
"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": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/schmelczerandras/timeline.git"
|
||||
"url": "git+https://github.com/schmelczerandras/photos.git"
|
||||
},
|
||||
"keywords": [
|
||||
"CV",
|
||||
"portfolio",
|
||||
"resume",
|
||||
"resumé"
|
||||
"photography",
|
||||
"photos",
|
||||
"gallery"
|
||||
],
|
||||
"author": "András Schmelczer",
|
||||
"license": "UNLICENSED",
|
||||
"bugs": {
|
||||
"url": "https://github.com/schmelczerandras/timeline/issues"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
"url": "https://github.com/schmelczerandras/photos/issues"
|
||||
},
|
||||
"homepage": "https://schmelczer.dev/photos/",
|
||||
"browserslist": [
|
||||
"defaults"
|
||||
],
|
||||
"homepage": "https://github.com/schmelczerandras/timeline#readme",
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^9.8.6",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"css-loader": "^3.6.0",
|
||||
"cssnano": "latest",
|
||||
"file-loader": "^5.1.0",
|
||||
"html-webpack-inline-source-plugin": "0.0.10",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"image-webpack-loader": "^6.0.0",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"prettier": "^1.19.1",
|
||||
"resolve-url-loader": "^3.1.1",
|
||||
"responsive-loader": "^1.2.0",
|
||||
"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"
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"eslint": "^10.4.1",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.95.0",
|
||||
"sharp": "^0.34.5",
|
||||
"stylelint": "^17.12.0",
|
||||
"stylelint-config-standard-scss": "^17.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
playwright.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 538 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/og-image.jpg
Normal file
|
After Width: | Height: | Size: 243 KiB |
4
public/robots.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://schmelczer.dev/photos/sitemap.xml
|
||||
26
public/site.webmanifest
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
public/sitemap.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?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>
|
||||
37
scripts/check-image-metadata.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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.`
|
||||
);
|
||||
84
scripts/check-photo-catalog.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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();
|
||||
248
scripts/generate-assets.mjs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
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();
|
||||
39
scripts/strip-image-metadata.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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.`);
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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;
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export const last = <T>(list: ArrayLike<T>): T =>
|
||||
list.length >= 1 ? list[list.length - 1] : undefined;
|
||||
100
src/index.html
|
|
@ -1,24 +1,76 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 name="theme-color" content="#A63446">
|
||||
<meta name="viewport" content="initial-scale=1.0" />
|
||||
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:100" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>András Schmelczer</h1>
|
||||
</header>
|
||||
<main id="images">
|
||||
<section id="portraits" class="cluster"></section>
|
||||
<div id="frame-container">
|
||||
<img src="" alt="opened photo" id="frame-image"/>
|
||||
</div>
|
||||
<section id="landscapes" class="cluster"></section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Photos - András Schmelczer</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A small photo portfolio by András Schmelczer, with portraits, landscapes, city scenes, and quiet details."
|
||||
/>
|
||||
<meta name="theme-color" content="#a63446" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<link rel="canonical" href="__SITE_URL__" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
||||
<link rel="icon" href="favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
|
||||
<link rel="manifest" href="site.webmanifest" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="__SITE_URL__" />
|
||||
<meta property="og:title" content="Photos - András Schmelczer" />
|
||||
<meta
|
||||
property="og:description"
|
||||
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>
|
||||
|
|
|
|||
454
src/index.scss
|
|
@ -1,239 +1,287 @@
|
|||
@mixin center-children() {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
$breakpoint: 900px;
|
||||
|
||||
@mixin portrait() {
|
||||
@media (max-width: 900px) {
|
||||
@content;
|
||||
}
|
||||
:root {
|
||||
--color-text: #fff;
|
||||
--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 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,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: $accent-color solid $border-width * 1.1;
|
||||
outline-offset: -$border-width * 1.1;
|
||||
}
|
||||
|
||||
img {
|
||||
border: $border-width solid $text-color;
|
||||
box-sizing: content-box;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
background-color: $background-color;
|
||||
min-height: 100%;
|
||||
background: var(--color-background);
|
||||
color: var(--color-ink);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
block-size: 100dvh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@include portrait {
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
max-inline-size: 100%;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
@include landscape {
|
||||
&::-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;
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@include landscape {
|
||||
font-size: 3rem;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--color-focus);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
@include portrait {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
header {
|
||||
@include center-children;
|
||||
box-sizing: content-box;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
.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;
|
||||
}
|
||||
|
||||
@include portrait {
|
||||
width: 100%;
|
||||
height: 2.15ch;
|
||||
padding: $normal-margin 0;
|
||||
}
|
||||
.gallery {
|
||||
min-width: 0;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5rem, 10vmin) minmax(0, 1fr) minmax(
|
||||
7rem,
|
||||
14vmin
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@include landscape {
|
||||
width: 2.15ch;
|
||||
height: 100vh;
|
||||
padding: 0 $normal-margin;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: $text-color;
|
||||
background-color: $accent-color;
|
||||
padding: $normal-margin 0;
|
||||
.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%);
|
||||
}
|
||||
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
position: fixed;
|
||||
.thumbnail:hover,
|
||||
.thumbnail.is-selected {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
text-align: center;
|
||||
.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%);
|
||||
}
|
||||
|
||||
@include portrait {
|
||||
width: 100%;
|
||||
}
|
||||
.thumbnail--portrait {
|
||||
inline-size: 8vmin;
|
||||
}
|
||||
|
||||
@include landscape {
|
||||
width: 101vh;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
.thumbnail--landscape {
|
||||
inline-size: 12vmin;
|
||||
}
|
||||
|
||||
main#images {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.thumbnail picture,
|
||||
.thumbnail img {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
@include portrait {
|
||||
flex-direction: column;
|
||||
}
|
||||
.frame {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
.cluster {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.frame-content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
cursor: pointer;
|
||||
}
|
||||
.frame-figure {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
&#portraits > img {
|
||||
@include portrait {
|
||||
height: 12vmin;
|
||||
width: auto;
|
||||
}
|
||||
@include landscape {
|
||||
width: 8vmin;
|
||||
}
|
||||
}
|
||||
.frame-figure picture {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&#landscapes > img {
|
||||
@include portrait {
|
||||
height: 8vmin;
|
||||
width: auto;
|
||||
}
|
||||
@include landscape {
|
||||
width: 12vmin;
|
||||
}
|
||||
}
|
||||
.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%);
|
||||
}
|
||||
|
||||
img {
|
||||
@include portrait {
|
||||
margin: 0 $small-margin;
|
||||
}
|
||||
}
|
||||
.frame-figure figcaption {
|
||||
max-inline-size: min(42rem, 100%);
|
||||
color: var(--color-ink);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@include portrait {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
margin: $small-margin 0;
|
||||
padding: $small-margin 0;
|
||||
object-fit: contain;
|
||||
.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;
|
||||
}
|
||||
|
||||
:first-child {
|
||||
margin-left: 2 * $small-margin;
|
||||
}
|
||||
.js .slideshow-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 2 * $small-margin !important;
|
||||
}
|
||||
}
|
||||
.slideshow-toggle:hover {
|
||||
background: #5f1724;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < $breakpoint) {
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: var(--space-s);
|
||||
padding: var(--space-s);
|
||||
}
|
||||
|
||||
.slideshow-toggle {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
justify-self: center;
|
||||
writing-mode: horizontal-tb;
|
||||
transform: none;
|
||||
font-size: clamp(1.5rem, 7vw, 2rem);
|
||||
}
|
||||
|
||||
.gallery {
|
||||
min-height: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.rail {
|
||||
max-height: none;
|
||||
min-width: 0;
|
||||
overflow: auto hidden;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding: var(--space-xs) var(--space-s);
|
||||
}
|
||||
|
||||
.rail--portraits {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.rail--landscapes {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
inline-size: auto;
|
||||
}
|
||||
|
||||
.thumbnail--portrait img {
|
||||
block-size: 12vmin;
|
||||
inline-size: auto;
|
||||
}
|
||||
|
||||
.thumbnail--landscape img {
|
||||
block-size: 8vmin;
|
||||
inline-size: auto;
|
||||
}
|
||||
|
||||
.frame {
|
||||
grid-row: 2;
|
||||
padding: var(--space-s);
|
||||
}
|
||||
|
||||
.frame-figure img {
|
||||
max-block-size: calc(100dvh - 12rem);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
src/index.ts
|
|
@ -1,35 +1,19 @@
|
|||
import './index.scss';
|
||||
import './no-change/404.html';
|
||||
import './no-change/robots.txt';
|
||||
import { Photos } from './photos';
|
||||
import { photos } from './generated/photos';
|
||||
import { PhotoGallery, requiredElement } from './photos';
|
||||
|
||||
// @ts-ignore
|
||||
const images = require.context('./pictures', true, /.jpg$/);
|
||||
const imagePath = name => images(name, true);
|
||||
document.documentElement.classList.add('js');
|
||||
|
||||
const addSupportForTabNavigation = () =>
|
||||
(document.onkeydown = e => {
|
||||
if (e.key === ' ') {
|
||||
(document.activeElement as HTMLElement)?.click();
|
||||
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')
|
||||
const gallery = requiredElement<HTMLElement>('#gallery', HTMLElement);
|
||||
const frame = requiredElement<HTMLElement>('#frame-content', HTMLElement);
|
||||
const toggle = requiredElement<HTMLButtonElement>(
|
||||
'#slideshow-toggle',
|
||||
HTMLButtonElement
|
||||
);
|
||||
|
||||
addSupportForTabNavigation();
|
||||
window.addEventListener('resize', sizeFrame);
|
||||
sizeFrame();
|
||||
new PhotoGallery({
|
||||
photos,
|
||||
gallery,
|
||||
frame,
|
||||
toggle,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
export type ResponsiveImage = {
|
||||
srcSet: string;
|
||||
src: string;
|
||||
placeholder: string;
|
||||
width: number;
|
||||
height: number;
|
||||
images: Array<{
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
197
src/photo-catalog.json
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
28
src/photo-types.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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;
|
||||
};
|
||||
129
src/photos.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
322
src/photos.ts
|
|
@ -1,109 +1,273 @@
|
|||
import { ResponsiveImage } from './model/responsive-image';
|
||||
import { createImage } from './helper/create-image';
|
||||
import type { ImageSource, Photo } from './photo-types';
|
||||
|
||||
export class Photos {
|
||||
private readonly images: Array<HTMLImageElement>;
|
||||
private _selectedId: number;
|
||||
private timerId: NodeJS.Timeout = null;
|
||||
private shouldNotChange = false;
|
||||
const AUTO_ADVANCE_DELAY_MS = 7000;
|
||||
const FRAME_SIZES =
|
||||
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)';
|
||||
|
||||
public constructor(
|
||||
images: Array<ResponsiveImage>,
|
||||
private readonly landscapesContainer: HTMLElement,
|
||||
private readonly portraitsContainer: HTMLElement,
|
||||
private readonly pictureFrame: HTMLImageElement
|
||||
) {
|
||||
this.images = this.convertImages(images);
|
||||
this.placeImages();
|
||||
this.addEventListeners();
|
||||
this.startAutoChange();
|
||||
this.selectedId = 0;
|
||||
type GalleryOptions = {
|
||||
readonly photos: readonly Photo[];
|
||||
readonly gallery: HTMLElement;
|
||||
readonly frame: HTMLElement;
|
||||
readonly toggle: HTMLButtonElement;
|
||||
readonly autoAdvance?: boolean;
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
private get selectedId(): number {
|
||||
return this._selectedId;
|
||||
return element;
|
||||
};
|
||||
|
||||
export const wrapIndex = (value: number, count: number): number => {
|
||||
if (count <= 0) {
|
||||
throw new Error('Cannot wrap an index without items.');
|
||||
}
|
||||
|
||||
private set selectedId(value: number) {
|
||||
if (value < 0) {
|
||||
value += this.images.length - 1;
|
||||
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._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)
|
||||
)
|
||||
this.thumbnails = this.options.photos.map((photo) =>
|
||||
this.thumbnailFor(photo)
|
||||
);
|
||||
this.addEventListeners();
|
||||
this.select(0);
|
||||
|
||||
return imgs.sort((a, b) => a.tabIndex - b.tabIndex);
|
||||
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
|
||||
this.startAutoAdvance();
|
||||
} else {
|
||||
this.updateToggle(false);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
private addEventListeners(): void {
|
||||
this.options.gallery.addEventListener('click', (event) => {
|
||||
const thumbnail = (event.target as Element).closest<HTMLAnchorElement>(
|
||||
'[data-photo-id]'
|
||||
);
|
||||
|
||||
if (!thumbnail) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private addEventListeners() {
|
||||
window.document.addEventListener('keydown', e => {
|
||||
switch (e.key) {
|
||||
event.preventDefault();
|
||||
this.selectById(thumbnail.dataset.photoId, { pause: true });
|
||||
});
|
||||
|
||||
this.options.gallery.addEventListener('keydown', (event) => {
|
||||
if (!this.options.gallery.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
this.selectedId--;
|
||||
event.preventDefault();
|
||||
this.select(this.selectedIndex - 1, {
|
||||
focusThumbnail: true,
|
||||
pause: true,
|
||||
});
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
this.selectedId++;
|
||||
event.preventDefault();
|
||||
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;
|
||||
case 'Tab':
|
||||
if (this.timerId !== null) {
|
||||
clearInterval(this.timerId);
|
||||
this.timerId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.document.addEventListener(
|
||||
'scroll',
|
||||
() => (this.shouldNotChange = true)
|
||||
this.options.gallery.addEventListener('pointerdown', () =>
|
||||
this.pauseAutoAdvance()
|
||||
);
|
||||
this.options.gallery.addEventListener(
|
||||
'wheel',
|
||||
() => this.pauseAutoAdvance(),
|
||||
{ passive: true }
|
||||
);
|
||||
this.options.gallery.addEventListener(
|
||||
'touchstart',
|
||||
() => this.pauseAutoAdvance(),
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
this.images.forEach((image, i) =>
|
||||
image.addEventListener('click', () => (this.selectedId = i))
|
||||
);
|
||||
}
|
||||
|
||||
private startAutoChange() {
|
||||
this.timerId = setInterval(() => {
|
||||
if (!this.shouldNotChange) {
|
||||
this.selectedId++;
|
||||
this.options.toggle.addEventListener('click', () => {
|
||||
if (this.timerId === null) {
|
||||
this.startAutoAdvance();
|
||||
} else {
|
||||
this.pauseAutoAdvance();
|
||||
}
|
||||
this.shouldNotChange = false;
|
||||
}, 7000);
|
||||
});
|
||||
|
||||
this.reducedMotion.addEventListener('change', () => {
|
||||
if (this.reducedMotion.matches) {
|
||||
this.pauseAutoAdvance();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadToFrame(img: HTMLImageElement) {
|
||||
this.pictureFrame.src = img.src;
|
||||
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 static isLandscape(
|
||||
image: HTMLImageElement | ResponsiveImage
|
||||
): boolean {
|
||||
return image.width > image.height;
|
||||
private selectById(id: string | undefined, options: SelectOptions): void {
|
||||
const index = this.options.photos.findIndex((photo) => photo.id === id);
|
||||
|
||||
if (index >= 0) {
|
||||
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);
|
||||
|
||||
if (selected && shouldFocus) {
|
||||
thumbnail.focus({ preventScroll: true });
|
||||
thumbnail.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startAutoAdvance(): void {
|
||||
this.pauseAutoAdvance(false);
|
||||
|
||||
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 {
|
||||
if (this.timerId !== null) {
|
||||
window.clearInterval(this.timerId);
|
||||
this.timerId = null;
|
||||
}
|
||||
|
||||
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.6 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 9.6 MiB After Width: | Height: | Size: 7.4 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 6.7 MiB After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 6.5 MiB After Width: | Height: | Size: 2 MiB |
|
Before Width: | Height: | Size: 7.6 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 381 KiB After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 462 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 429 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 381 KiB After Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 369 KiB After Width: | Height: | Size: 437 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 820 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 465 KiB After Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 691 KiB |
|
Before Width: | Height: | Size: 810 KiB After Width: | Height: | Size: 920 KiB |
9
stylelint.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
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,11 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"noImplicitAny": false,
|
||||
"module": "es6",
|
||||
"target": "es5",
|
||||
"sourceMap": true,
|
||||
"allowJs": true,
|
||||
"downlevelIteration": true
|
||||
}
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": 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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
58
vite.config.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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()],
|
||||
});
|
||||
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
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'),
|
||||
},
|
||||
};
|
||||