diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index a27453d..85a0c53 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -10,13 +10,13 @@ on: permissions: contents: read +concurrency: + group: 'photos-${{ github.ref }}' + cancel-in-progress: true + jobs: validate: runs-on: docker - # Superseded validation runs can be cancelled freely. - concurrency: - group: 'photos-validate-${{ github.ref }}' - cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -49,11 +49,6 @@ jobs: runs-on: docker needs: validate 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: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index adbc559..b3bc429 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules dist +target .DS_Store public/static/photos src/generated diff --git a/README.md b/README.md index 7183167..62d8866 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Run the full local validation suite with: npm run lint:check ``` -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. +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: diff --git a/e2e/gallery.spec.ts b/e2e/gallery.spec.ts index 9c9f715..6b436e0 100644 --- a/e2e/gallery.spec.ts +++ b/e2e/gallery.spec.ts @@ -5,7 +5,7 @@ test('renders the gallery and supports keyboard navigation', async ({ }) => { await page.goto('/'); - await expect(page.locator('.thumbnail')).toHaveCount(54); + await expect(page.locator('.thumbnail')).toHaveCount(39); await expect(page.locator('#frame-image')).toBeVisible(); const firstAlt = await page.locator('#frame-image').getAttribute('alt'); diff --git a/og-image.jpg b/og-image.jpg new file mode 100644 index 0000000..3f5cbdb Binary files /dev/null and b/og-image.jpg differ diff --git a/package.json b/package.json index f9c746c..763d877 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "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/generate-assets.mjs && node scripts/check-image-metadata.mjs && tsc --noEmit && eslint . && stylelint \"src/**/*.scss\" && prettier --check .", + "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" @@ -32,6 +32,9 @@ "url": "https://github.com/schmelczerandras/photos/issues" }, "homepage": "https://schmelczer.dev/photos/", + "browserslist": [ + "defaults" + ], "devDependencies": { "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", diff --git a/scripts/check-image-metadata.mjs b/scripts/check-image-metadata.mjs index a561237..d66ece3 100644 --- a/scripts/check-image-metadata.mjs +++ b/scripts/check-image-metadata.mjs @@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, '..'); const pictureDir = path.join(rootDir, 'src/pictures'); const files = (await readdir(pictureDir)).filter((file) => - /\.jpe?g$/i.test(file) + file.toLowerCase().endsWith('.jpg') ); const offenders = []; diff --git a/scripts/check-photo-catalog.mjs b/scripts/check-photo-catalog.mjs new file mode 100644 index 0000000..9af4d0f --- /dev/null +++ b/scripts/check-photo-catalog.mjs @@ -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(); diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index a23f92a..812ec80 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -15,7 +15,7 @@ const formats = [ { type: 'image/avif', extension: 'avif', - options: { quality: 45, effort: 4 }, + options: { quality: 45, effort: 0 }, }, { type: 'image/webp', extension: 'webp', options: { quality: 78 } }, { @@ -55,19 +55,6 @@ const uniqueWidthsFor = (sourceWidth) => { const renderSource = ({ type, srcset }, 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 = ( photo, sizes, @@ -93,17 +80,7 @@ const renderThumbnail = (photo, isCurrent) => { (source) => source.type === 'image/jpeg' ).variants[0]; - // The frame is built at runtime by cloning this thumbnail's , 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 ` + return ` ${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)} `; }; @@ -111,37 +88,17 @@ const renderThumbnail = (photo, isCurrent) => { const renderFrame = (photo) => `
${renderPicture( photo, - frameSizes(photo), + '(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)', 'id="frame-image" decoding="async" fetchpriority="high"' )}
${escapeHtml(photo.caption)}
`; -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 sourceFiles = new Set( - (await readdir(sourceDir)).filter((file) => /\.jpe?g$/i.test(file)) + (await readdir(sourceDir)).filter((file) => + file.toLowerCase().endsWith('.jpg') + ) ); const catalogFiles = new Set(catalog.map((entry) => entry.file)); @@ -159,14 +116,13 @@ const assertCatalogMatchesFiles = async (catalog) => { }; const generate = async () => { - const rawCatalog = JSON.parse(await readFile(catalogPath, 'utf8')); - validateCatalog(rawCatalog); - - const catalog = rawCatalog.map((entry, index) => ({ - ...entry, - id: slugify(entry.file), - order: index + 1, - })); + 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 }); @@ -249,6 +205,11 @@ 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 portraits = photos .filter((photo) => photo.orientation === 'portrait') @@ -269,6 +230,7 @@ ${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( diff --git a/scripts/strip-image-metadata.mjs b/scripts/strip-image-metadata.mjs index 361544d..fa1760d 100644 --- a/scripts/strip-image-metadata.mjs +++ b/scripts/strip-image-metadata.mjs @@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, '..'); const pictureDir = path.join(rootDir, 'src/pictures'); const files = (await readdir(pictureDir)).filter((file) => - /\.jpe?g$/i.test(file) + file.toLowerCase().endsWith('.jpg') ); let stripped = 0; diff --git a/src/index.html b/src/index.html index 00e7388..7b1f6f0 100644 --- a/src/index.html +++ b/src/index.html @@ -56,7 +56,7 @@ -
+
diff --git a/src/index.scss b/src/index.scss index 626ee3e..f02e448 100644 --- a/src/index.scss +++ b/src/index.scss @@ -32,6 +32,7 @@ html { } body { + min-height: 100dvh; block-size: 100dvh; margin: 0; display: grid; @@ -68,6 +69,7 @@ img { 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); @@ -204,8 +206,18 @@ img { background: #5f1724; } +@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); } diff --git a/src/index.ts b/src/index.ts index 8ff05be..6c06b9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import './index.scss'; +import { photos } from './generated/photos'; import { PhotoGallery, requiredElement } from './photos'; document.documentElement.classList.add('js'); @@ -11,6 +12,7 @@ const toggle = requiredElement( ); new PhotoGallery({ + photos, gallery, frame, toggle, diff --git a/src/photo-catalog.json b/src/photo-catalog.json index 759fab0..3a61a8c 100644 --- a/src/photo-catalog.json +++ b/src/photo-catalog.json @@ -193,80 +193,5 @@ "file": "web-42.jpg", "alt": "Warm sunset over a grassy field with low hills in the distance.", "caption": "Sunset hills" - }, - { - "file": "_DSC2636-sterling.jpg", - "alt": "A leaf-strewn forest path winding between autumn trees into misty green woodland under soft grey light.", - "caption": "Autumn forest path" - }, - { - "file": "_DSC2675-sterling.jpg", - "alt": "Tall conifer trees rising into pale morning mist beneath a soft grey sky.", - "caption": "Misty conifers" - }, - { - "file": "_DSC2701-sterling.jpg", - "alt": "A winding earthen path through a misty evergreen forest with bare branches holding a few orange autumn leaves.", - "caption": "Misty forest path" - }, - { - "file": "20230703_211416_F9DA2848.jpg", - "alt": "Black-and-white view of a bicycle's handlebars before a city skyline of tall office towers.", - "caption": "Bicycle and skyline" - }, - { - "file": "20230703_211419_C25C2367.jpg", - "alt": "Sunlit dirt path winding through silhouetted pine trees in soft black-and-white woodland light.", - "caption": "Woodland path" - }, - { - "file": "20230703_211420_C19E401F.jpg", - "alt": "Silhouetted pine trees in a misty forest with soft light filtering through their leaning trunks.", - "caption": "Pine silhouettes" - }, - { - "file": "IMG_3907.JPG", - "alt": "White chalk cliffs topped with green grass descend to a pebble beach beside a pale grey sea.", - "caption": "White chalk cliffs" - }, - { - "file": "IMG_5297.JPG", - "alt": "The Berlin TV Tower seen from below, its sphere catching a sunburst against blue sky and clouds.", - "caption": "Tower sunburst" - }, - { - "file": "IMG_5299.JPG", - "alt": "A bouquet of pink and cream roses in paper, with a dark cat ornament glowing behind in warm light.", - "caption": "Roses and cat" - }, - { - "file": "colour-20.jpg", - "alt": "Empty chain swings in a fenced playground, lit by warm low sun with bright lens flare.", - "caption": "Empty swings" - }, - { - "file": "colour-7.jpg", - "alt": "Metal fire-escape stairs zigzag across a brick building beneath bare branches and a pale dusk sky.", - "caption": "Fire escape" - }, - { - "file": "colour-8.jpg", - "alt": "The BT Tower fades into mist above a pale building, framed by bare branches under grey sky.", - "caption": "Tower in mist" - }, - { - "file": "colour-25.jpg", - "alt": "A red road-closed sign and yellow barriers before a bright orange perforated building facade under grey sky.", - "caption": "Road closed" - }, - { - "file": "colour-40.jpg", - "alt": "Aerial view over a hazy city with railway lines stretching toward the horizon beside a river.", - "caption": "Railways from above" - }, - { - "file": "colour-41.jpg", - "alt": "Aerial view of a hazy city skyline at sunset, with golden light rays piercing through dark clouds.", - "caption": "Sunset rays" } ] diff --git a/src/photo-types.ts b/src/photo-types.ts new file mode 100644 index 0000000..d9cf124 --- /dev/null +++ b/src/photo-types.ts @@ -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; +}; diff --git a/src/photos.test.ts b/src/photos.test.ts index 695ebe9..0629f71 100644 --- a/src/photos.test.ts +++ b/src/photos.test.ts @@ -1,33 +1,47 @@ import { describe, expect, it, vi } from 'vitest'; import { PhotoGallery, wrapIndex } from './photos'; +import type { Photo } from './photo-types'; -const thumbnail = (id: string, order: number, alt: string): string => ` - - - - - ${alt} - - `; +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)', @@ -42,22 +56,27 @@ const setup = () => { document.body.innerHTML = `
- ${thumbnail('one', 1, 'First photo')} - ${thumbnail('two', 2, 'Second photo')} + One + Two
-
+
`; const gallery = document.querySelector('#gallery'); - const frame = document.querySelector('#frame-content'); + const frame = document.querySelector('#frame'); const toggle = document.querySelector('#toggle'); if (!gallery || !frame || !toggle) { throw new Error('Test DOM failed to initialize.'); } - return { gallery, frame, toggle }; + return { + gallery, + frame, + toggle, + photos: [photo('one', 'First photo'), photo('two', 'Second photo')], + }; }; describe('wrapIndex', () => { @@ -75,9 +94,9 @@ describe('wrapIndex', () => { }); describe('PhotoGallery', () => { - it('builds the frame from the clicked thumbnail and marks it current', () => { - const { gallery, frame, toggle } = setup(); - new PhotoGallery({ gallery, frame, toggle, autoAdvance: false }); + 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('[data-photo-id="two"]') @@ -85,11 +104,7 @@ describe('PhotoGallery', () => { new MouseEvent('click', { bubbles: true, cancelable: true }) ); - 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(frame.querySelector('img')?.alt).toBe('Second photo'); expect( gallery .querySelector('[data-photo-id="two"]') @@ -97,9 +112,9 @@ describe('PhotoGallery', () => { ).toBe('true'); }); - it('wraps with arrow-key navigation', () => { - const { gallery, frame, toggle } = setup(); - new PhotoGallery({ gallery, frame, toggle, autoAdvance: false }); + 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', { diff --git a/src/photos.ts b/src/photos.ts index e29304a..6c575b1 100644 --- a/src/photos.ts +++ b/src/photos.ts @@ -1,6 +1,11 @@ +import type { ImageSource, Photo } from './photo-types'; + const AUTO_ADVANCE_DELAY_MS = 7000; +const FRAME_SIZES = + '(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)'; type GalleryOptions = { + readonly photos: readonly Photo[]; readonly gallery: HTMLElement; readonly frame: HTMLElement; readonly toggle: HTMLButtonElement; @@ -33,44 +38,44 @@ export const wrapIndex = (value: number, count: number): number => { return ((value % count) + count) % count; }; -// Build the frame figure by cloning the thumbnail's own . The variant -// files are identical to the frame's, so the only differences are the larger -// fallback src, the frame-appropriate `sizes`, and the caption — all carried on -// the thumbnail's data-* attributes by the asset generator. -const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => { +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 original = thumbnail.querySelector('picture'); - if (!original) { - throw new Error( - `Thumbnail is missing a picture: ${thumbnail.dataset.photoId}` - ); - } - - const picture = original.cloneNode(true) as HTMLElement; - const image = picture.querySelector('img'); - if (!image) { - throw new Error( - `Thumbnail is missing an image: ${thumbnail.dataset.photoId}` - ); - } - - const sizes = thumbnail.dataset.frameSizes ?? ''; - for (const element of picture.querySelectorAll('source, img')) { - element.setAttribute('sizes', sizes); + 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'; - if (thumbnail.dataset.frameSrc) { - image.src = thumbnail.dataset.frameSrc; - } - image.removeAttribute('loading'); + 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 = thumbnail.dataset.caption ?? ''; + caption.textContent = photo.caption; figure.append(picture, caption); @@ -86,19 +91,15 @@ export class PhotoGallery { ); public constructor(private readonly options: GalleryOptions) { - this.thumbnails = Array.from( - options.gallery.querySelectorAll('[data-photo-id]') - ).sort((a, b) => Number(a.dataset.order) - Number(b.dataset.order)); - - if (this.thumbnails.length === 0) { + if (options.photos.length === 0) { throw new Error('The gallery needs at least one photo.'); } + this.thumbnails = this.options.photos.map((photo) => + this.thumbnailFor(photo) + ); this.addEventListeners(); - - // 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); + this.select(0); if (this.options.autoAdvance !== false && !this.reducedMotion.matches) { this.startAutoAdvance(); @@ -122,6 +123,10 @@ export class PhotoGallery { }); this.options.gallery.addEventListener('keydown', (event) => { + if (!this.options.gallery.contains(event.target as Node)) { + return; + } + switch (event.key) { case 'ArrowLeft': event.preventDefault(); @@ -143,7 +148,7 @@ export class PhotoGallery { break; case 'End': event.preventDefault(); - this.select(this.thumbnails.length - 1, { + this.select(this.options.photos.length - 1, { focusThumbnail: true, pause: true, }); @@ -159,6 +164,11 @@ export class PhotoGallery { () => this.pauseAutoAdvance(), { passive: true } ); + this.options.gallery.addEventListener( + 'touchstart', + () => this.pauseAutoAdvance(), + { passive: true } + ); this.options.toggle.addEventListener('click', () => { if (this.timerId === null) { @@ -175,38 +185,45 @@ export class PhotoGallery { }); } - private selectById(id: string | undefined, options: SelectOptions): void { - const index = this.thumbnails.findIndex( - (thumbnail) => thumbnail.dataset.photoId === id + private thumbnailFor(photo: Photo): HTMLAnchorElement { + const thumbnail = this.options.gallery.querySelector( + `[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 { + 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.thumbnails.length); - const thumbnail = this.thumbnails[this.selectedIndex]; + this.selectedIndex = wrapIndex(value, this.options.photos.length); + const photo = this.options.photos[this.selectedIndex]; - if (!thumbnail) { + if (!photo) { throw new Error('Selected photo is missing.'); } - this.options.frame.replaceChildren(createFrameFigure(thumbnail)); - this.updateSelectedThumbnail( - this.selectedIndex, - options.focusThumbnail ?? false - ); + this.options.frame.replaceChildren(createFrameFigure(photo)); + this.updateSelectedThumbnail(photo, options.focusThumbnail ?? false); if (options.pause) { this.pauseAutoAdvance(); } } - private updateSelectedThumbnail(index: number, shouldFocus: boolean): void { - this.thumbnails.forEach((thumbnail, i) => { - const selected = i === index; + 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 { @@ -221,7 +238,7 @@ export class PhotoGallery { inline: 'nearest', }); } - }); + } } private startAutoAdvance(): void { diff --git a/src/pictures/20230703_211416_F9DA2848.jpg b/src/pictures/20230703_211416_F9DA2848.jpg deleted file mode 100644 index 08b4b6e..0000000 Binary files a/src/pictures/20230703_211416_F9DA2848.jpg and /dev/null differ diff --git a/src/pictures/20230703_211419_C25C2367.jpg b/src/pictures/20230703_211419_C25C2367.jpg deleted file mode 100644 index d239c87..0000000 Binary files a/src/pictures/20230703_211419_C25C2367.jpg and /dev/null differ diff --git a/src/pictures/20230703_211420_C19E401F.jpg b/src/pictures/20230703_211420_C19E401F.jpg deleted file mode 100644 index 3df0c02..0000000 Binary files a/src/pictures/20230703_211420_C19E401F.jpg and /dev/null differ diff --git a/src/pictures/IMG_3907.JPG b/src/pictures/IMG_3907.JPG deleted file mode 100644 index fdea83c..0000000 Binary files a/src/pictures/IMG_3907.JPG and /dev/null differ diff --git a/src/pictures/IMG_5297.JPG b/src/pictures/IMG_5297.JPG deleted file mode 100644 index 788c43f..0000000 Binary files a/src/pictures/IMG_5297.JPG and /dev/null differ diff --git a/src/pictures/IMG_5299.JPG b/src/pictures/IMG_5299.JPG deleted file mode 100644 index d67d052..0000000 Binary files a/src/pictures/IMG_5299.JPG and /dev/null differ diff --git a/src/pictures/_DSC2636-sterling.jpg b/src/pictures/_DSC2636-sterling.jpg deleted file mode 100644 index 67dbba8..0000000 Binary files a/src/pictures/_DSC2636-sterling.jpg and /dev/null differ diff --git a/src/pictures/_DSC2675-sterling.jpg b/src/pictures/_DSC2675-sterling.jpg deleted file mode 100644 index c31b2d3..0000000 Binary files a/src/pictures/_DSC2675-sterling.jpg and /dev/null differ diff --git a/src/pictures/_DSC2701-sterling.jpg b/src/pictures/_DSC2701-sterling.jpg deleted file mode 100644 index 4fecf39..0000000 Binary files a/src/pictures/_DSC2701-sterling.jpg and /dev/null differ diff --git a/src/pictures/colour-20.jpg b/src/pictures/colour-20.jpg deleted file mode 100644 index d44f5ab..0000000 Binary files a/src/pictures/colour-20.jpg and /dev/null differ diff --git a/src/pictures/colour-25.jpg b/src/pictures/colour-25.jpg deleted file mode 100644 index e34a294..0000000 Binary files a/src/pictures/colour-25.jpg and /dev/null differ diff --git a/src/pictures/colour-40.jpg b/src/pictures/colour-40.jpg deleted file mode 100644 index b0b7e73..0000000 Binary files a/src/pictures/colour-40.jpg and /dev/null differ diff --git a/src/pictures/colour-41.jpg b/src/pictures/colour-41.jpg deleted file mode 100644 index 75ea03a..0000000 Binary files a/src/pictures/colour-41.jpg and /dev/null differ diff --git a/src/pictures/colour-7.jpg b/src/pictures/colour-7.jpg deleted file mode 100644 index e6989bd..0000000 Binary files a/src/pictures/colour-7.jpg and /dev/null differ diff --git a/src/pictures/colour-8.jpg b/src/pictures/colour-8.jpg deleted file mode 100644 index 19be3d7..0000000 Binary files a/src/pictures/colour-8.jpg and /dev/null differ