diff --git a/package-lock.json b/package-lock.json index 5d3b85d..020b29d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "devDependencies": { "@eslint/js": "^10.0.1", + "@plausible-analytics/tracker": "^0.4.5", "@playwright/test": "^1.60.0", "@types/node": "^25.9.1", "eslint": "^10.4.1", @@ -1517,6 +1518,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@plausible-analytics/tracker": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz", + "integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==", + "dev": true, + "license": "MIT" + }, "node_modules/@playwright/test": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", diff --git a/package.json b/package.json index f9c746c..aefdc8c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.60.0", "vite": "^8.0.14", - "vitest": "^4.1.7" + "vitest": "^4.1.7", + "@plausible-analytics/tracker": "^0.4.5" } } diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index 673465f..282ea0c 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -10,21 +10,30 @@ 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]; +// The top width (3200) covers HiDPI/large displays: the frame is shown at up +// to ~2200 CSS px, so a retina viewer needs ~4000+ device px. Sources that are +// smaller aren't upscaled (withoutEnlargement), so we only pay for it where the +// resolution actually exists. +const widths = [480, 960, 1600, 2400, 3200]; const formats = [ { type: 'image/avif', extension: 'avif', - options: { quality: 45, effort: 4 }, + options: { quality: 58, effort: 4 }, }, - { type: 'image/webp', extension: 'webp', options: { quality: 78 } }, + { type: 'image/webp', extension: 'webp', options: { quality: 82 } }, { type: 'image/jpeg', extension: 'jpg', - options: { quality: 82, mozjpeg: true, progressive: true }, + options: { quality: 85, mozjpeg: true, progressive: true }, }, ]; +// Variant filenames are keyed by a hash so unchanged files are skipped. The key +// must cover the encoding settings too — otherwise a quality/width change keeps +// serving the stale files because their names never change. +const encodingKey = JSON.stringify({ widths, formats }); + const slugify = (value) => value .replace(/\.[^.]+$/, '') @@ -185,7 +194,11 @@ const generate = async () => { 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 hash = createHash('sha256') + .update(input) + .update(encodingKey) + .digest('hex') + .slice(0, 10); const metadata = await sharp(input).rotate().metadata(); if (!metadata.width || !metadata.height) { diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..6791a27 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,53 @@ +import { + init as plausibleInit, + track as plausibleTrack, + type PlausibleEventOptions, +} from '@plausible-analytics/tracker'; + +const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true; +const ANALYTICS_DOMAIN = 'schmelczer.dev/photos'; +const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status'; +const ANALYTICS_LOGGING = import.meta.env.DEV; + +let isInitialized = false; + +const track = (eventName: string, options: PlausibleEventOptions = {}) => { + try { + plausibleTrack(eventName, options); + } catch (error) { + console.warn(`Could not track analytics event "${eventName}".`, error); + } +}; + +export const initAnalytics = () => { + if (isInitialized) { + return; + } + + try { + plausibleInit({ + domain: ANALYTICS_DOMAIN, + endpoint: ANALYTICS_ENDPOINT, + autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS, + logging: ANALYTICS_LOGGING, + }); + isInitialized = true; + } catch (error) { + console.warn('Could not initialize analytics.', error); + } +}; + +export const trackPhotoView = ({ + photoId, + source, +}: { + photoId: string; + source: string; +}) => { + track('Photo View', { + props: { + photoId, + source, + }, + }); +}; diff --git a/src/index.ts b/src/index.ts index 8ff05be..7a7d316 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,11 @@ import './index.scss'; +import { initAnalytics, trackPhotoView } from './analytics'; import { PhotoGallery, requiredElement } from './photos'; document.documentElement.classList.add('js'); +initAnalytics(); + const gallery = requiredElement('#gallery', HTMLElement); const frame = requiredElement('#frame-content', HTMLElement); const toggle = requiredElement( @@ -14,4 +17,5 @@ new PhotoGallery({ gallery, frame, toggle, + onSelect: trackPhotoView, }); diff --git a/src/photos.ts b/src/photos.ts index e29304a..b528c99 100644 --- a/src/photos.ts +++ b/src/photos.ts @@ -5,11 +5,13 @@ type GalleryOptions = { readonly frame: HTMLElement; readonly toggle: HTMLButtonElement; readonly autoAdvance?: boolean; + readonly onSelect?: (info: { photoId: string; source: string }) => void; }; type SelectOptions = { readonly focusThumbnail?: boolean; readonly pause?: boolean; + readonly source?: string; }; export const requiredElement = ( @@ -118,7 +120,10 @@ export class PhotoGallery { } event.preventDefault(); - this.selectById(thumbnail.dataset.photoId, { pause: true }); + this.selectById(thumbnail.dataset.photoId, { + pause: true, + source: 'thumbnail', + }); }); this.options.gallery.addEventListener('keydown', (event) => { @@ -128,6 +133,7 @@ export class PhotoGallery { this.select(this.selectedIndex - 1, { focusThumbnail: true, pause: true, + source: 'keyboard', }); break; case 'ArrowRight': @@ -135,17 +141,23 @@ export class PhotoGallery { this.select(this.selectedIndex + 1, { focusThumbnail: true, pause: true, + source: 'keyboard', }); break; case 'Home': event.preventDefault(); - this.select(0, { focusThumbnail: true, pause: true }); + this.select(0, { + focusThumbnail: true, + pause: true, + source: 'keyboard', + }); break; case 'End': event.preventDefault(); this.select(this.thumbnails.length - 1, { focusThumbnail: true, pause: true, + source: 'keyboard', }); break; } @@ -202,6 +214,15 @@ export class PhotoGallery { if (options.pause) { this.pauseAutoAdvance(); } + + // Only user-initiated selections carry a source; auto-advance stays + // untracked so the slideshow doesn't flood analytics every few seconds. + if (options.source) { + this.options.onSelect?.({ + photoId: thumbnail.dataset.photoId ?? '', + source: options.source, + }); + } } private updateSelectedThumbnail(index: number, shouldFocus: boolean): void {