Compare commits
2 commits
3193c509a7
...
4777985f40
| Author | SHA1 | Date | |
|---|---|---|---|
| 4777985f40 | |||
| 0e3067db19 |
6 changed files with 108 additions and 8 deletions
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@plausible-analytics/tracker": "^0.4.5",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
|
|
@ -1517,6 +1518,13 @@
|
||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.60.0",
|
"version": "1.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.60.0",
|
"typescript-eslint": "^8.60.0",
|
||||||
"vite": "^8.0.14",
|
"vite": "^8.0.14",
|
||||||
"vitest": "^4.1.7"
|
"vitest": "^4.1.7",
|
||||||
|
"@plausible-analytics/tracker": "^0.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,30 @@ const sourceDir = path.join(rootDir, 'src/pictures');
|
||||||
const publicPhotoDir = path.join(rootDir, 'public/static/photos');
|
const publicPhotoDir = path.join(rootDir, 'public/static/photos');
|
||||||
const generatedDir = path.join(rootDir, 'src/generated');
|
const generatedDir = path.join(rootDir, 'src/generated');
|
||||||
const catalogPath = path.join(rootDir, 'src/photo-catalog.json');
|
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 = [
|
const formats = [
|
||||||
{
|
{
|
||||||
type: 'image/avif',
|
type: 'image/avif',
|
||||||
extension: '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',
|
type: 'image/jpeg',
|
||||||
extension: 'jpg',
|
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) =>
|
const slugify = (value) =>
|
||||||
value
|
value
|
||||||
.replace(/\.[^.]+$/, '')
|
.replace(/\.[^.]+$/, '')
|
||||||
|
|
@ -185,7 +194,11 @@ const generate = async () => {
|
||||||
for (const entry of catalog) {
|
for (const entry of catalog) {
|
||||||
const inputPath = path.join(sourceDir, entry.file);
|
const inputPath = path.join(sourceDir, entry.file);
|
||||||
const input = await readFile(inputPath);
|
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();
|
const metadata = await sharp(input).rotate().metadata();
|
||||||
|
|
||||||
if (!metadata.width || !metadata.height) {
|
if (!metadata.width || !metadata.height) {
|
||||||
|
|
|
||||||
53
src/analytics.ts
Normal file
53
src/analytics.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
import { initAnalytics, trackPhotoView } from './analytics';
|
||||||
import { PhotoGallery, requiredElement } from './photos';
|
import { PhotoGallery, requiredElement } from './photos';
|
||||||
|
|
||||||
document.documentElement.classList.add('js');
|
document.documentElement.classList.add('js');
|
||||||
|
|
||||||
|
initAnalytics();
|
||||||
|
|
||||||
const gallery = requiredElement<HTMLElement>('#gallery', HTMLElement);
|
const gallery = requiredElement<HTMLElement>('#gallery', HTMLElement);
|
||||||
const frame = requiredElement<HTMLElement>('#frame-content', HTMLElement);
|
const frame = requiredElement<HTMLElement>('#frame-content', HTMLElement);
|
||||||
const toggle = requiredElement<HTMLButtonElement>(
|
const toggle = requiredElement<HTMLButtonElement>(
|
||||||
|
|
@ -14,4 +17,5 @@ new PhotoGallery({
|
||||||
gallery,
|
gallery,
|
||||||
frame,
|
frame,
|
||||||
toggle,
|
toggle,
|
||||||
|
onSelect: trackPhotoView,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ type GalleryOptions = {
|
||||||
readonly frame: HTMLElement;
|
readonly frame: HTMLElement;
|
||||||
readonly toggle: HTMLButtonElement;
|
readonly toggle: HTMLButtonElement;
|
||||||
readonly autoAdvance?: boolean;
|
readonly autoAdvance?: boolean;
|
||||||
|
readonly onSelect?: (info: { photoId: string; source: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SelectOptions = {
|
type SelectOptions = {
|
||||||
readonly focusThumbnail?: boolean;
|
readonly focusThumbnail?: boolean;
|
||||||
readonly pause?: boolean;
|
readonly pause?: boolean;
|
||||||
|
readonly source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requiredElement = <T extends Element>(
|
export const requiredElement = <T extends Element>(
|
||||||
|
|
@ -118,7 +120,10 @@ export class PhotoGallery {
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.selectById(thumbnail.dataset.photoId, { pause: true });
|
this.selectById(thumbnail.dataset.photoId, {
|
||||||
|
pause: true,
|
||||||
|
source: 'thumbnail',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.options.gallery.addEventListener('keydown', (event) => {
|
this.options.gallery.addEventListener('keydown', (event) => {
|
||||||
|
|
@ -128,6 +133,7 @@ export class PhotoGallery {
|
||||||
this.select(this.selectedIndex - 1, {
|
this.select(this.selectedIndex - 1, {
|
||||||
focusThumbnail: true,
|
focusThumbnail: true,
|
||||||
pause: true,
|
pause: true,
|
||||||
|
source: 'keyboard',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
|
|
@ -135,17 +141,23 @@ export class PhotoGallery {
|
||||||
this.select(this.selectedIndex + 1, {
|
this.select(this.selectedIndex + 1, {
|
||||||
focusThumbnail: true,
|
focusThumbnail: true,
|
||||||
pause: true,
|
pause: true,
|
||||||
|
source: 'keyboard',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.select(0, { focusThumbnail: true, pause: true });
|
this.select(0, {
|
||||||
|
focusThumbnail: true,
|
||||||
|
pause: true,
|
||||||
|
source: 'keyboard',
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.select(this.thumbnails.length - 1, {
|
this.select(this.thumbnails.length - 1, {
|
||||||
focusThumbnail: true,
|
focusThumbnail: true,
|
||||||
pause: true,
|
pause: true,
|
||||||
|
source: 'keyboard',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +214,15 @@ export class PhotoGallery {
|
||||||
if (options.pause) {
|
if (options.pause) {
|
||||||
this.pauseAutoAdvance();
|
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 {
|
private updateSelectedThumbnail(index: number, shouldFocus: boolean): void {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue