Compare commits

...

3 commits

Author SHA1 Message Date
5bdf68eabd Migrate to vite
All checks were successful
Check & deploy / validate (push) Successful in 3m0s
Check & deploy / deploy (push) Successful in 55s
2026-05-31 13:23:10 +01:00
1914991250 TIdy up 2026-05-31 13:06:47 +01:00
35a962935c Clean up 2026-05-31 11:13:13 +01:00
82 changed files with 7220 additions and 670 deletions

View file

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

@ -1 +0,0 @@
og-image.jpg filter=lfs diff=lfs merge=lfs -text

5
.gitignore vendored
View file

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

@ -0,0 +1 @@
22.13.0

View file

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 243 KiB

Before After
Before After

5495
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

4
public/robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://schmelczer.dev/photos/sitemap.xml

26
public/site.webmanifest Normal file
View 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
View 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>

View 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.`
);

View 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
View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
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();

View 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.`);

View file

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

View file

@ -1,2 +0,0 @@
export const last = <T>(list: ArrayLike<T>): T =>
list.length >= 1 ? list[list.length - 1] : undefined;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
User-agent: *
Allow: /

197
src/photo-catalog.json Normal file
View 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
View 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
View 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');
});
});

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 MiB

After

Width:  |  Height:  |  Size: 3 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 MiB

After

Width:  |  Height:  |  Size: 7.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 MiB

After

Width:  |  Height:  |  Size: 2 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 KiB

After

Width:  |  Height:  |  Size: 482 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

After

Width:  |  Height:  |  Size: 462 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 429 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 KiB

After

Width:  |  Height:  |  Size: 436 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 392 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 437 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 820 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

After

Width:  |  Height:  |  Size: 572 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 691 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 KiB

After

Width:  |  Height:  |  Size: 920 KiB

Before After
Before After

9
stylelint.config.js Normal file
View 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,
},
};

View file

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

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.test.ts'],
},
});

View file

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