Clean up
This commit is contained in:
parent
5bdf68eabd
commit
d5aa9c43bb
15 changed files with 166 additions and 285 deletions
|
|
@ -10,13 +10,13 @@ on:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: 'photos-${{ github.ref }}'
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
# Superseded validation runs can be cancelled freely.
|
||||||
|
concurrency:
|
||||||
|
group: 'photos-validate-${{ github.ref }}'
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -49,6 +49,11 @@ jobs:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
needs: validate
|
needs: validate
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
# Never cancel an in-flight deploy: rsync --delete must finish or the live
|
||||||
|
# site can be left half-deleted. Newer deploys queue behind this one.
|
||||||
|
concurrency:
|
||||||
|
group: 'photos-deploy-${{ github.ref }}'
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
target
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
public/static/photos
|
public/static/photos
|
||||||
src/generated
|
src/generated
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ Run the full local validation suite with:
|
||||||
npm run lint:check
|
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.
|
This generates assets (validating the photo catalog against `src/pictures/` in the process), checks source photos for EXIF/XMP/IPTC metadata, type-checks, lints TypeScript and SCSS, and checks formatting.
|
||||||
|
|
||||||
Run unit tests separately:
|
Run unit tests separately:
|
||||||
|
|
||||||
|
|
|
||||||
BIN
og-image.jpg
BIN
og-image.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 243 KiB |
|
|
@ -7,7 +7,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/generate-assets.mjs && vite --host 0.0.0.0",
|
"dev": "node scripts/generate-assets.mjs && vite --host 0.0.0.0",
|
||||||
"lint": "eslint . --fix && stylelint \"src/**/*.scss\" --fix && prettier --write .",
|
"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 .",
|
"lint:check": "node scripts/generate-assets.mjs && node scripts/check-image-metadata.mjs && tsc --noEmit && eslint . && stylelint \"src/**/*.scss\" && prettier --check .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"build": "node scripts/generate-assets.mjs && vite build"
|
"build": "node scripts/generate-assets.mjs && vite build"
|
||||||
|
|
@ -32,9 +32,6 @@
|
||||||
"url": "https://github.com/schmelczerandras/photos/issues"
|
"url": "https://github.com/schmelczerandras/photos/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://schmelczer.dev/photos/",
|
"homepage": "https://schmelczer.dev/photos/",
|
||||||
"browserslist": [
|
|
||||||
"defaults"
|
|
||||||
],
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, '..');
|
||||||
const pictureDir = path.join(rootDir, 'src/pictures');
|
const pictureDir = path.join(rootDir, 'src/pictures');
|
||||||
|
|
||||||
const files = (await readdir(pictureDir)).filter((file) =>
|
const files = (await readdir(pictureDir)).filter((file) =>
|
||||||
file.toLowerCase().endsWith('.jpg')
|
/\.jpe?g$/i.test(file)
|
||||||
);
|
);
|
||||||
|
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import { readFile, readdir } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const rootDir = path.resolve(__dirname, '..');
|
|
||||||
const sourceDir = path.join(rootDir, 'src/pictures');
|
|
||||||
const catalogPath = path.join(rootDir, 'src/photo-catalog.json');
|
|
||||||
|
|
||||||
const isJpeg = (file) => /\.(jpe?g)$/i.test(file);
|
|
||||||
|
|
||||||
const formatList = (items) => items.map((item) => `- ${item}`).join('\n');
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const sourceFiles = (await readdir(sourceDir)).filter(isJpeg).sort();
|
|
||||||
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
|
|
||||||
|
|
||||||
if (!Array.isArray(catalog)) {
|
|
||||||
throw new Error('Photo catalog must be an array.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalogFiles = catalog.map((entry, index) => {
|
|
||||||
if (!entry || typeof entry !== 'object') {
|
|
||||||
throw new Error(`Photo catalog entry ${index + 1} must be an object.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof entry.file !== 'string' || entry.file.trim() === '') {
|
|
||||||
throw new Error(`Photo catalog entry ${index + 1} is missing "file".`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.file;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sourceFileSet = new Set(sourceFiles);
|
|
||||||
const catalogFileSet = new Set(catalogFiles);
|
|
||||||
const duplicateFiles = catalogFiles
|
|
||||||
.filter((file, index) => catalogFiles.indexOf(file) !== index)
|
|
||||||
.filter((file, index, files) => files.indexOf(file) === index)
|
|
||||||
.sort();
|
|
||||||
const missingFromCatalog = sourceFiles.filter(
|
|
||||||
(file) => !catalogFileSet.has(file)
|
|
||||||
);
|
|
||||||
const missingFromPictures = catalogFiles
|
|
||||||
.filter((file) => !sourceFileSet.has(file))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
if (
|
|
||||||
duplicateFiles.length > 0 ||
|
|
||||||
missingFromCatalog.length > 0 ||
|
|
||||||
missingFromPictures.length > 0
|
|
||||||
) {
|
|
||||||
const sections = [];
|
|
||||||
|
|
||||||
if (duplicateFiles.length > 0) {
|
|
||||||
sections.push(
|
|
||||||
`Duplicate catalog file entries:\n${formatList(duplicateFiles)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingFromCatalog.length > 0) {
|
|
||||||
sections.push(
|
|
||||||
`Photos missing from src/photo-catalog.json:\n${formatList(
|
|
||||||
missingFromCatalog
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingFromPictures.length > 0) {
|
|
||||||
sections.push(
|
|
||||||
`Catalog entries missing from src/pictures:\n${formatList(
|
|
||||||
missingFromPictures
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(sections.join('\n\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Photo catalog includes all ${sourceFiles.length} source photos.`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
await main();
|
|
||||||
|
|
@ -15,7 +15,7 @@ const formats = [
|
||||||
{
|
{
|
||||||
type: 'image/avif',
|
type: 'image/avif',
|
||||||
extension: 'avif',
|
extension: 'avif',
|
||||||
options: { quality: 45, effort: 0 },
|
options: { quality: 45, effort: 4 },
|
||||||
},
|
},
|
||||||
{ type: 'image/webp', extension: 'webp', options: { quality: 78 } },
|
{ type: 'image/webp', extension: 'webp', options: { quality: 78 } },
|
||||||
{
|
{
|
||||||
|
|
@ -55,6 +55,19 @@ const uniqueWidthsFor = (sourceWidth) => {
|
||||||
const renderSource = ({ type, srcset }, sizes) =>
|
const renderSource = ({ type, srcset }, sizes) =>
|
||||||
`<source type="${type}" srcset="${escapeHtml(srcset)}" sizes="${escapeHtml(sizes)}">`;
|
`<source type="${type}" srcset="${escapeHtml(srcset)}" sizes="${escapeHtml(sizes)}">`;
|
||||||
|
|
||||||
|
// The frame is bounded by BOTH the available width and the viewport height
|
||||||
|
// (CSS max-block-size). For a portrait, the height cap is what limits the
|
||||||
|
// rendered width, so the displayed width is min(widthCap, heightCap * aspect).
|
||||||
|
// Expressing that here stops tall photos from over-fetching a too-wide variant.
|
||||||
|
const frameSizes = (photo) => {
|
||||||
|
const aspect = photo.aspectRatio;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`(max-width: 899px) min(calc(100vw - 2rem), calc((100vh - 12rem) * ${aspect})), ` +
|
||||||
|
`min(calc(100vw - 18rem), calc((100vh - 5rem) * ${aspect}))`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderPicture = (
|
const renderPicture = (
|
||||||
photo,
|
photo,
|
||||||
sizes,
|
sizes,
|
||||||
|
|
@ -80,7 +93,17 @@ const renderThumbnail = (photo, isCurrent) => {
|
||||||
(source) => source.type === 'image/jpeg'
|
(source) => source.type === 'image/jpeg'
|
||||||
).variants[0];
|
).variants[0];
|
||||||
|
|
||||||
return `<a class="thumbnail thumbnail--${photo.orientation}" href="${photo.fallback.src}" data-photo-id="${photo.id}"${isCurrent ? ' aria-current="true"' : ''}>
|
// The frame is built at runtime by cloning this thumbnail's <picture>, so the
|
||||||
|
// data the frame needs but the thumbnail markup lacks is carried on data-*.
|
||||||
|
const dataAttributes = [
|
||||||
|
`data-photo-id="${photo.id}"`,
|
||||||
|
`data-order="${photo.order}"`,
|
||||||
|
`data-caption="${escapeHtml(photo.caption)}"`,
|
||||||
|
`data-frame-src="${photo.fallback.src}"`,
|
||||||
|
`data-frame-sizes="${escapeHtml(frameSizes(photo))}"`,
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
return `<a class="thumbnail thumbnail--${photo.orientation}" href="${photo.fallback.src}" ${dataAttributes}${isCurrent ? ' aria-current="true"' : ''}>
|
||||||
${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)}
|
${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)}
|
||||||
</a>`;
|
</a>`;
|
||||||
};
|
};
|
||||||
|
|
@ -88,17 +111,37 @@ const renderThumbnail = (photo, isCurrent) => {
|
||||||
const renderFrame = (photo) => `<figure class="frame-figure">
|
const renderFrame = (photo) => `<figure class="frame-figure">
|
||||||
${renderPicture(
|
${renderPicture(
|
||||||
photo,
|
photo,
|
||||||
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)',
|
frameSizes(photo),
|
||||||
'id="frame-image" decoding="async" fetchpriority="high"'
|
'id="frame-image" decoding="async" fetchpriority="high"'
|
||||||
)}
|
)}
|
||||||
<figcaption id="frame-caption">${escapeHtml(photo.caption)}</figcaption>
|
<figcaption id="frame-caption">${escapeHtml(photo.caption)}</figcaption>
|
||||||
</figure>`;
|
</figure>`;
|
||||||
|
|
||||||
|
const validateCatalog = (catalog) => {
|
||||||
|
if (!Array.isArray(catalog)) {
|
||||||
|
throw new Error('Photo catalog must be an array.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const [index, entry] of catalog.entries()) {
|
||||||
|
if (!entry || typeof entry.file !== 'string' || entry.file.trim() === '') {
|
||||||
|
throw new Error(
|
||||||
|
`Photo catalog entry ${index + 1} needs a non-empty "file".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(entry.file)) {
|
||||||
|
throw new Error(`Duplicate photo catalog entry: ${entry.file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(entry.file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const assertCatalogMatchesFiles = async (catalog) => {
|
const assertCatalogMatchesFiles = async (catalog) => {
|
||||||
const sourceFiles = new Set(
|
const sourceFiles = new Set(
|
||||||
(await readdir(sourceDir)).filter((file) =>
|
(await readdir(sourceDir)).filter((file) => /\.jpe?g$/i.test(file))
|
||||||
file.toLowerCase().endsWith('.jpg')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const catalogFiles = new Set(catalog.map((entry) => entry.file));
|
const catalogFiles = new Set(catalog.map((entry) => entry.file));
|
||||||
|
|
||||||
|
|
@ -116,13 +159,14 @@ const assertCatalogMatchesFiles = async (catalog) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const generate = async () => {
|
const generate = async () => {
|
||||||
const catalog = JSON.parse(await readFile(catalogPath, 'utf8')).map(
|
const rawCatalog = JSON.parse(await readFile(catalogPath, 'utf8'));
|
||||||
(entry, index) => ({
|
validateCatalog(rawCatalog);
|
||||||
|
|
||||||
|
const catalog = rawCatalog.map((entry, index) => ({
|
||||||
...entry,
|
...entry,
|
||||||
id: slugify(entry.file),
|
id: slugify(entry.file),
|
||||||
order: index + 1,
|
order: index + 1,
|
||||||
})
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
await assertCatalogMatchesFiles(catalog);
|
await assertCatalogMatchesFiles(catalog);
|
||||||
await rm(publicPhotoDir, { recursive: true, force: true });
|
await rm(publicPhotoDir, { recursive: true, force: true });
|
||||||
|
|
@ -205,11 +249,6 @@ const generate = async () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 firstPhoto = photos[0];
|
||||||
const portraits = photos
|
const portraits = photos
|
||||||
.filter((photo) => photo.orientation === 'portrait')
|
.filter((photo) => photo.orientation === 'portrait')
|
||||||
|
|
@ -230,7 +269,6 @@ ${landscapes}
|
||||||
<!-- /landscapes -->
|
<!-- /landscapes -->
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await writeFile(path.join(generatedDir, 'photos.ts'), module);
|
|
||||||
await writeFile(path.join(generatedDir, 'gallery.html'), galleryHtml);
|
await writeFile(path.join(generatedDir, 'gallery.html'), galleryHtml);
|
||||||
console.log(
|
console.log(
|
||||||
`Generated ${photos.length} photos and ${photos.reduce(
|
`Generated ${photos.length} photos and ${photos.reduce(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, '..');
|
||||||
const pictureDir = path.join(rootDir, 'src/pictures');
|
const pictureDir = path.join(rootDir, 'src/pictures');
|
||||||
|
|
||||||
const files = (await readdir(pictureDir)).filter((file) =>
|
const files = (await readdir(pictureDir)).filter((file) =>
|
||||||
file.toLowerCase().endsWith('.jpg')
|
/\.jpe?g$/i.test(file)
|
||||||
);
|
);
|
||||||
|
|
||||||
let stripped = 0;
|
let stripped = 0;
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
<!-- gallery:portraits -->
|
<!-- gallery:portraits -->
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="frame" aria-live="polite">
|
<section class="frame">
|
||||||
<div id="frame-content" class="frame-content">
|
<div id="frame-content" class="frame-content">
|
||||||
<!-- gallery:frame -->
|
<!-- gallery:frame -->
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100dvh;
|
|
||||||
block-size: 100dvh;
|
block-size: 100dvh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -69,7 +68,6 @@ img {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.5rem, 4vmin, 3rem);
|
font-size: clamp(1.5rem, 4vmin, 3rem);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
writing-mode: vertical-rl;
|
writing-mode: vertical-rl;
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
|
|
@ -206,18 +204,8 @@ img {
|
||||||
background: #5f1724;
|
background: #5f1724;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width < $breakpoint) {
|
@media (width < $breakpoint) {
|
||||||
body {
|
body {
|
||||||
min-height: 100dvh;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
import { photos } from './generated/photos';
|
|
||||||
import { PhotoGallery, requiredElement } from './photos';
|
import { PhotoGallery, requiredElement } from './photos';
|
||||||
|
|
||||||
document.documentElement.classList.add('js');
|
document.documentElement.classList.add('js');
|
||||||
|
|
@ -12,7 +11,6 @@ const toggle = requiredElement<HTMLButtonElement>(
|
||||||
);
|
);
|
||||||
|
|
||||||
new PhotoGallery({
|
new PhotoGallery({
|
||||||
photos,
|
|
||||||
gallery,
|
gallery,
|
||||||
frame,
|
frame,
|
||||||
toggle,
|
toggle,
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
export type PhotoOrientation = 'landscape' | 'portrait';
|
|
||||||
export type ImageMimeType = 'image/avif' | 'image/webp' | 'image/jpeg';
|
|
||||||
|
|
||||||
export type ImageVariant = {
|
|
||||||
readonly src: string;
|
|
||||||
readonly width: number;
|
|
||||||
readonly height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImageSource = {
|
|
||||||
readonly type: ImageMimeType;
|
|
||||||
readonly variants: readonly ImageVariant[];
|
|
||||||
readonly srcset: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Photo = {
|
|
||||||
readonly id: string;
|
|
||||||
readonly file: string;
|
|
||||||
readonly order: number;
|
|
||||||
readonly alt: string;
|
|
||||||
readonly caption: string;
|
|
||||||
readonly orientation: PhotoOrientation;
|
|
||||||
readonly width: number;
|
|
||||||
readonly height: number;
|
|
||||||
readonly aspectRatio: number;
|
|
||||||
readonly sources: readonly ImageSource[];
|
|
||||||
readonly fallback: ImageVariant;
|
|
||||||
};
|
|
||||||
|
|
@ -1,47 +1,33 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { PhotoGallery, wrapIndex } from './photos';
|
import { PhotoGallery, wrapIndex } from './photos';
|
||||||
import type { Photo } from './photo-types';
|
|
||||||
|
|
||||||
const variant = (name: string) => ({
|
const thumbnail = (id: string, order: number, alt: string): string => `
|
||||||
src: `static/photos/${name}.jpg`,
|
<a
|
||||||
width: 320,
|
class="thumbnail"
|
||||||
height: 240,
|
href="static/photos/${id}-2400.jpg"
|
||||||
});
|
data-photo-id="${id}"
|
||||||
|
data-order="${order}"
|
||||||
const photo = (id: string, alt: string): Photo => ({
|
data-caption="${alt}"
|
||||||
id,
|
data-frame-src="static/photos/${id}-2400.jpg"
|
||||||
file: `${id}.jpg`,
|
data-frame-sizes="100vw"
|
||||||
order: 1,
|
>
|
||||||
alt,
|
<picture>
|
||||||
caption: alt,
|
<source type="image/avif" srcset="static/photos/${id}-480.avif 480w" sizes="8vmin" />
|
||||||
orientation: 'landscape',
|
<source type="image/webp" srcset="static/photos/${id}-480.webp 480w" sizes="8vmin" />
|
||||||
width: 320,
|
<img
|
||||||
height: 240,
|
src="static/photos/${id}-480.jpg"
|
||||||
aspectRatio: 1.3333,
|
srcset="static/photos/${id}-480.jpg 480w"
|
||||||
sources: [
|
sizes="8vmin"
|
||||||
{
|
width="320"
|
||||||
type: 'image/avif',
|
height="240"
|
||||||
variants: [variant(`${id}.avif`)],
|
alt="${alt}"
|
||||||
srcset: `static/photos/${id}.avif 320w`,
|
loading="lazy"
|
||||||
},
|
decoding="async"
|
||||||
{
|
/>
|
||||||
type: 'image/webp',
|
</picture>
|
||||||
variants: [variant(`${id}.webp`)],
|
</a>`;
|
||||||
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 = () => {
|
const setup = () => {
|
||||||
vi.stubGlobal('CSS', {
|
|
||||||
escape: (value: string) => value,
|
|
||||||
});
|
|
||||||
vi.stubGlobal('matchMedia', () => ({
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
matches: false,
|
matches: false,
|
||||||
media: '(prefers-reduced-motion: reduce)',
|
media: '(prefers-reduced-motion: reduce)',
|
||||||
|
|
@ -56,27 +42,22 @@ const setup = () => {
|
||||||
|
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<main id="gallery">
|
<main id="gallery">
|
||||||
<a href="static/photos/one.jpg" data-photo-id="one">One</a>
|
${thumbnail('one', 1, 'First photo')}
|
||||||
<a href="static/photos/two.jpg" data-photo-id="two">Two</a>
|
${thumbnail('two', 2, 'Second photo')}
|
||||||
</main>
|
</main>
|
||||||
<section id="frame"></section>
|
<section class="frame"><div id="frame-content"></div></section>
|
||||||
<button id="toggle" type="button">Play</button>
|
<button id="toggle" type="button">Play</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const gallery = document.querySelector<HTMLElement>('#gallery');
|
const gallery = document.querySelector<HTMLElement>('#gallery');
|
||||||
const frame = document.querySelector<HTMLElement>('#frame');
|
const frame = document.querySelector<HTMLElement>('#frame-content');
|
||||||
const toggle = document.querySelector<HTMLButtonElement>('#toggle');
|
const toggle = document.querySelector<HTMLButtonElement>('#toggle');
|
||||||
|
|
||||||
if (!gallery || !frame || !toggle) {
|
if (!gallery || !frame || !toggle) {
|
||||||
throw new Error('Test DOM failed to initialize.');
|
throw new Error('Test DOM failed to initialize.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { gallery, frame, toggle };
|
||||||
gallery,
|
|
||||||
frame,
|
|
||||||
toggle,
|
|
||||||
photos: [photo('one', 'First photo'), photo('two', 'Second photo')],
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('wrapIndex', () => {
|
describe('wrapIndex', () => {
|
||||||
|
|
@ -94,9 +75,9 @@ describe('wrapIndex', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PhotoGallery', () => {
|
describe('PhotoGallery', () => {
|
||||||
it('updates the frame and selected state when a thumbnail is clicked', () => {
|
it('builds the frame from the clicked thumbnail and marks it current', () => {
|
||||||
const { gallery, frame, toggle, photos } = setup();
|
const { gallery, frame, toggle } = setup();
|
||||||
new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false });
|
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
|
||||||
|
|
||||||
gallery
|
gallery
|
||||||
.querySelector<HTMLAnchorElement>('[data-photo-id="two"]')
|
.querySelector<HTMLAnchorElement>('[data-photo-id="two"]')
|
||||||
|
|
@ -104,7 +85,11 @@ describe('PhotoGallery', () => {
|
||||||
new MouseEvent('click', { bubbles: true, cancelable: true })
|
new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(frame.querySelector('img')?.alt).toBe('Second photo');
|
const image = frame.querySelector('img');
|
||||||
|
expect(image?.alt).toBe('Second photo');
|
||||||
|
expect(image?.id).toBe('frame-image');
|
||||||
|
expect(image?.getAttribute('src')).toBe('static/photos/two-2400.jpg');
|
||||||
|
expect(frame.querySelector('figcaption')?.textContent).toBe('Second photo');
|
||||||
expect(
|
expect(
|
||||||
gallery
|
gallery
|
||||||
.querySelector('[data-photo-id="two"]')
|
.querySelector('[data-photo-id="two"]')
|
||||||
|
|
@ -112,9 +97,9 @@ describe('PhotoGallery', () => {
|
||||||
).toBe('true');
|
).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses scoped arrow-key navigation', () => {
|
it('wraps with arrow-key navigation', () => {
|
||||||
const { gallery, frame, toggle, photos } = setup();
|
const { gallery, frame, toggle } = setup();
|
||||||
new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false });
|
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
|
||||||
|
|
||||||
gallery.dispatchEvent(
|
gallery.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', {
|
new KeyboardEvent('keydown', {
|
||||||
|
|
|
||||||
125
src/photos.ts
125
src/photos.ts
|
|
@ -1,11 +1,6 @@
|
||||||
import type { ImageSource, Photo } from './photo-types';
|
|
||||||
|
|
||||||
const AUTO_ADVANCE_DELAY_MS = 7000;
|
const AUTO_ADVANCE_DELAY_MS = 7000;
|
||||||
const FRAME_SIZES =
|
|
||||||
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)';
|
|
||||||
|
|
||||||
type GalleryOptions = {
|
type GalleryOptions = {
|
||||||
readonly photos: readonly Photo[];
|
|
||||||
readonly gallery: HTMLElement;
|
readonly gallery: HTMLElement;
|
||||||
readonly frame: HTMLElement;
|
readonly frame: HTMLElement;
|
||||||
readonly toggle: HTMLButtonElement;
|
readonly toggle: HTMLButtonElement;
|
||||||
|
|
@ -38,44 +33,44 @@ export const wrapIndex = (value: number, count: number): number => {
|
||||||
return ((value % count) + count) % count;
|
return ((value % count) + count) % count;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createSource = (source: ImageSource): HTMLSourceElement => {
|
// Build the frame figure by cloning the thumbnail's own <picture>. The variant
|
||||||
const element = document.createElement('source');
|
// files are identical to the frame's, so the only differences are the larger
|
||||||
element.type = source.type;
|
// fallback src, the frame-appropriate `sizes`, and the caption — all carried on
|
||||||
element.srcset = source.srcset;
|
// the thumbnail's data-* attributes by the asset generator.
|
||||||
element.sizes = FRAME_SIZES;
|
const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => {
|
||||||
|
|
||||||
return element;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFrameFigure = (photo: Photo): HTMLElement => {
|
|
||||||
const figure = document.createElement('figure');
|
const figure = document.createElement('figure');
|
||||||
figure.className = 'frame-figure';
|
figure.className = 'frame-figure';
|
||||||
|
|
||||||
const picture = document.createElement('picture');
|
const original = thumbnail.querySelector('picture');
|
||||||
|
if (!original) {
|
||||||
for (const source of photo.sources.filter(
|
throw new Error(
|
||||||
({ type }) => type !== 'image/jpeg'
|
`Thumbnail is missing a picture: ${thumbnail.dataset.photoId}`
|
||||||
)) {
|
);
|
||||||
picture.append(createSource(source));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const jpeg = photo.sources.find(({ type }) => type === 'image/jpeg');
|
const picture = original.cloneNode(true) as HTMLElement;
|
||||||
const image = document.createElement('img');
|
const image = picture.querySelector('img');
|
||||||
image.id = 'frame-image';
|
if (!image) {
|
||||||
image.src = photo.fallback.src;
|
throw new Error(
|
||||||
image.srcset = jpeg?.srcset ?? '';
|
`Thumbnail is missing an image: ${thumbnail.dataset.photoId}`
|
||||||
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 sizes = thumbnail.dataset.frameSizes ?? '';
|
||||||
|
for (const element of picture.querySelectorAll('source, img')) {
|
||||||
|
element.setAttribute('sizes', sizes);
|
||||||
|
}
|
||||||
|
|
||||||
|
image.id = 'frame-image';
|
||||||
|
if (thumbnail.dataset.frameSrc) {
|
||||||
|
image.src = thumbnail.dataset.frameSrc;
|
||||||
|
}
|
||||||
|
image.removeAttribute('loading');
|
||||||
|
image.decoding = 'async';
|
||||||
|
|
||||||
const caption = document.createElement('figcaption');
|
const caption = document.createElement('figcaption');
|
||||||
caption.id = 'frame-caption';
|
caption.id = 'frame-caption';
|
||||||
caption.textContent = photo.caption;
|
caption.textContent = thumbnail.dataset.caption ?? '';
|
||||||
|
|
||||||
figure.append(picture, caption);
|
figure.append(picture, caption);
|
||||||
|
|
||||||
|
|
@ -91,15 +86,19 @@ export class PhotoGallery {
|
||||||
);
|
);
|
||||||
|
|
||||||
public constructor(private readonly options: GalleryOptions) {
|
public constructor(private readonly options: GalleryOptions) {
|
||||||
if (options.photos.length === 0) {
|
this.thumbnails = Array.from(
|
||||||
|
options.gallery.querySelectorAll<HTMLAnchorElement>('[data-photo-id]')
|
||||||
|
).sort((a, b) => Number(a.dataset.order) - Number(b.dataset.order));
|
||||||
|
|
||||||
|
if (this.thumbnails.length === 0) {
|
||||||
throw new Error('The gallery needs at least one photo.');
|
throw new Error('The gallery needs at least one photo.');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.thumbnails = this.options.photos.map((photo) =>
|
|
||||||
this.thumbnailFor(photo)
|
|
||||||
);
|
|
||||||
this.addEventListeners();
|
this.addEventListeners();
|
||||||
this.select(0);
|
|
||||||
|
// The frame for photo 0 is already server-rendered, so just mark the
|
||||||
|
// selected thumbnail; rebuilding it would discard the in-flight LCP image.
|
||||||
|
this.updateSelectedThumbnail(0, false);
|
||||||
|
|
||||||
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
|
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
|
||||||
this.startAutoAdvance();
|
this.startAutoAdvance();
|
||||||
|
|
@ -123,10 +122,6 @@ export class PhotoGallery {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.options.gallery.addEventListener('keydown', (event) => {
|
this.options.gallery.addEventListener('keydown', (event) => {
|
||||||
if (!this.options.gallery.contains(event.target as Node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -148,7 +143,7 @@ export class PhotoGallery {
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.select(this.options.photos.length - 1, {
|
this.select(this.thumbnails.length - 1, {
|
||||||
focusThumbnail: true,
|
focusThumbnail: true,
|
||||||
pause: true,
|
pause: true,
|
||||||
});
|
});
|
||||||
|
|
@ -164,11 +159,6 @@ export class PhotoGallery {
|
||||||
() => this.pauseAutoAdvance(),
|
() => this.pauseAutoAdvance(),
|
||||||
{ passive: true }
|
{ passive: true }
|
||||||
);
|
);
|
||||||
this.options.gallery.addEventListener(
|
|
||||||
'touchstart',
|
|
||||||
() => this.pauseAutoAdvance(),
|
|
||||||
{ passive: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.options.toggle.addEventListener('click', () => {
|
this.options.toggle.addEventListener('click', () => {
|
||||||
if (this.timerId === null) {
|
if (this.timerId === null) {
|
||||||
|
|
@ -185,20 +175,10 @@ export class PhotoGallery {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private thumbnailFor(photo: Photo): HTMLAnchorElement {
|
|
||||||
const thumbnail = this.options.gallery.querySelector<HTMLAnchorElement>(
|
|
||||||
`[data-photo-id="${CSS.escape(photo.id)}"]`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!thumbnail) {
|
|
||||||
throw new Error(`Missing thumbnail for ${photo.id}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return thumbnail;
|
|
||||||
}
|
|
||||||
|
|
||||||
private selectById(id: string | undefined, options: SelectOptions): void {
|
private selectById(id: string | undefined, options: SelectOptions): void {
|
||||||
const index = this.options.photos.findIndex((photo) => photo.id === id);
|
const index = this.thumbnails.findIndex(
|
||||||
|
(thumbnail) => thumbnail.dataset.photoId === id
|
||||||
|
);
|
||||||
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
this.select(index, options);
|
this.select(index, options);
|
||||||
|
|
@ -206,24 +186,27 @@ export class PhotoGallery {
|
||||||
}
|
}
|
||||||
|
|
||||||
private select(value: number, options: SelectOptions = {}): void {
|
private select(value: number, options: SelectOptions = {}): void {
|
||||||
this.selectedIndex = wrapIndex(value, this.options.photos.length);
|
this.selectedIndex = wrapIndex(value, this.thumbnails.length);
|
||||||
const photo = this.options.photos[this.selectedIndex];
|
const thumbnail = this.thumbnails[this.selectedIndex];
|
||||||
|
|
||||||
if (!photo) {
|
if (!thumbnail) {
|
||||||
throw new Error('Selected photo is missing.');
|
throw new Error('Selected photo is missing.');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.options.frame.replaceChildren(createFrameFigure(photo));
|
this.options.frame.replaceChildren(createFrameFigure(thumbnail));
|
||||||
this.updateSelectedThumbnail(photo, options.focusThumbnail ?? false);
|
this.updateSelectedThumbnail(
|
||||||
|
this.selectedIndex,
|
||||||
|
options.focusThumbnail ?? false
|
||||||
|
);
|
||||||
|
|
||||||
if (options.pause) {
|
if (options.pause) {
|
||||||
this.pauseAutoAdvance();
|
this.pauseAutoAdvance();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSelectedThumbnail(photo: Photo, shouldFocus: boolean): void {
|
private updateSelectedThumbnail(index: number, shouldFocus: boolean): void {
|
||||||
for (const thumbnail of this.thumbnails) {
|
this.thumbnails.forEach((thumbnail, i) => {
|
||||||
const selected = thumbnail.dataset.photoId === photo.id;
|
const selected = i === index;
|
||||||
if (selected) {
|
if (selected) {
|
||||||
thumbnail.setAttribute('aria-current', 'true');
|
thumbnail.setAttribute('aria-current', 'true');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -238,7 +221,7 @@ export class PhotoGallery {
|
||||||
inline: 'nearest',
|
inline: 'nearest',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private startAutoAdvance(): void {
|
private startAutoAdvance(): void {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue