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/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 {