This commit is contained in:
Andras Schmelczer 2026-05-31 13:06:47 +01:00
parent 35a962935c
commit 1914991250
19 changed files with 991 additions and 487 deletions

View file

@ -30,8 +30,11 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Lint and static checks
run: npm run lint:check
- name: Unit tests
run: npm test
- name: Build
run: npm run build
@ -65,5 +68,4 @@ jobs:
- name: Copy build to host pages mount
run: |
apt update && apt install -y rsync
mkdir -p /pages/photos
rsync -a --delete dist/ /pages/photos/
rsync -a --delete --mkpath --chmod=ugo=rwx dist/ /pages/photos/

21
e2e/gallery.spec.ts Normal file
View file

@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
test('renders the gallery and supports keyboard navigation', async ({
page,
}) => {
await page.goto('/');
await expect(page.locator('.thumbnail')).toHaveCount(39);
await expect(page.locator('#frame-image')).toBeVisible();
const firstAlt = await page.locator('#frame-image').getAttribute('alt');
await page.locator('.thumbnail').first().focus();
await page.keyboard.press('ArrowRight');
await expect(page.locator('.thumbnail[aria-current="true"]')).toHaveCount(1);
await expect(page.locator('#frame-image')).not.toHaveAttribute(
'alt',
firstAlt ?? ''
);
});

22
playwright.config.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'e2e',
timeout: 30_000,
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run build && npm run preview',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -7,9 +7,20 @@
"theme_color": "#a63446",
"icons": [
{
"src": "favicon.svg",
"sizes": "64x64",
"type": "image/svg+xml"
"src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View file

@ -1,24 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Photos - András Schmelczer</title>
<meta name="description" content="A very simple page where I showcase some of my photos. Although I am not a professional photographer, I truly love capturing joyous moments.">
<meta name="theme-color" content="#A63446">
<meta name="viewport" content="initial-scale=1.0" />
<link href="https://fonts.googleapis.com/css?family=Roboto:100" rel="stylesheet">
</head>
<body>
<header>
<h1>András Schmelczer</h1>
</header>
<main id="images">
<section id="portraits" class="cluster"></section>
<div id="frame-container">
<img src="" alt="opened photo" id="frame-image"/>
</div>
<section id="landscapes" class="cluster"></section>
</main>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Photos - András Schmelczer</title>
<meta
name="description"
content="A small photo portfolio by András Schmelczer, with portraits, landscapes, city scenes, and quiet details."
/>
<meta name="theme-color" content="#a63446" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<link rel="canonical" href="__SITE_URL__" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
<link rel="icon" href="favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
<link rel="manifest" href="site.webmanifest" />
<meta property="og:type" content="website" />
<meta property="og:url" content="__SITE_URL__" />
<meta property="og:title" content="Photos - András Schmelczer" />
<meta
property="og:description"
content="Portraits, landscapes, city scenes, and quiet details photographed by András Schmelczer."
/>
<meta property="og:image" content="__OG_IMAGE_URL__" />
<meta property="og:image:alt" content="Photo portfolio preview" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Photos - András Schmelczer" />
<meta
name="twitter:description"
content="Portraits, landscapes, city scenes, and quiet details photographed by András Schmelczer."
/>
<meta name="twitter:image" content="__OG_IMAGE_URL__" />
</head>
<body>
<header class="site-header">
<h1>András Schmelczer</h1>
</header>
<main id="gallery" class="gallery" aria-label="Photo gallery">
<section
id="portraits"
class="rail rail--portraits"
aria-label="Portrait photos"
>
<!-- gallery:portraits -->
</section>
<section class="frame" aria-live="polite">
<div id="frame-content" class="frame-content">
<!-- gallery:frame -->
</div>
<button
id="slideshow-toggle"
class="slideshow-toggle"
type="button"
aria-pressed="false"
>
Play
</button>
</section>
<section
id="landscapes"
class="rail rail--landscapes"
aria-label="Landscape photos"
>
<!-- gallery:landscapes -->
</section>
</main>
<script type="module" src="/index.ts"></script>
</body>
</html>

View file

@ -1,239 +1,273 @@
@mixin center-children() {
display: flex;
justify-content: center;
align-items: center;
}
$breakpoint: 900px;
@mixin portrait() {
@media (max-width: 900px) {
@content;
}
:root {
--color-text: #fff;
--color-ink: #202124;
--color-accent: #a63446;
--color-accent-strong: #7c2130;
--color-background: #eee;
--color-focus: #ffd166;
--border-width: 5px;
--space-xs: 0.375rem;
--space-s: 0.75rem;
--space-m: 1.5rem;
}
@mixin landscape() {
@media (min-width: 900px) {
@content;
}
}
$text-color: #ffffff;
$accent-color: #a63446;
$background-color: #eeeeee;
$border-width: 5px;
$normal-margin: 24px;
$small-margin: 6px;
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:focus {
outline: $accent-color solid $border-width * 1.1;
outline-offset: -$border-width * 1.1;
}
img {
border: $border-width solid $text-color;
box-sizing: content-box;
width: 100%;
height: auto;
display: block;
}
html {
height: 100%;
overflow-x: hidden;
background-color: $background-color;
min-height: 100%;
background: var(--color-background);
color: var(--color-ink);
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
body {
display: flex;
overflow: hidden;
body {
min-height: 100dvh;
margin: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
}
@include portrait {
height: 100%;
flex-direction: column;
}
img {
display: block;
max-inline-size: 100%;
block-size: auto;
}
@include landscape {
&::-webkit-scrollbar-track,
&::-webkit-scrollbar {
background-color: transparent;
width: 12px;
}
&::-webkit-scrollbar-thumb {
background-color: $accent-color;
border-radius: 1000px;
}
}
h1,
header {
font-family: 'Roboto', serif;
font-weight: 100;
:focus {
outline: none;
}
@include landscape {
font-size: 3rem;
}
:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 4px;
}
@include portrait {
font-size: 2rem;
}
}
.site-header {
display: grid;
place-items: center;
background: var(--color-accent);
color: var(--color-text);
padding: var(--space-m) var(--space-s);
}
header {
@include center-children;
box-sizing: content-box;
height: 100%;
position: relative;
.site-header h1 {
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);
white-space: nowrap;
}
@include portrait {
width: 100%;
height: 2.15ch;
padding: $normal-margin 0;
}
.gallery {
min-width: 0;
min-height: 100dvh;
display: grid;
grid-template-columns: minmax(5rem, 10vmin) minmax(0, 1fr) minmax(
7rem,
14vmin
);
position: relative;
}
@include landscape {
width: 2.15ch;
height: 100vh;
padding: 0 $normal-margin;
}
.rail {
min-height: 0;
max-height: 100dvh;
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-s);
padding: var(--space-m) var(--space-s);
scrollbar-color: var(--color-accent) transparent;
}
h1 {
color: $text-color;
background-color: $accent-color;
padding: $normal-margin 0;
.thumbnail {
display: block;
flex: 0 0 auto;
border: var(--border-width) solid var(--color-text);
background: var(--color-text);
color: inherit;
box-shadow: 0 0 0 1px rgb(32 33 36 / 16%);
}
white-space: nowrap;
user-select: none;
z-index: 2;
position: fixed;
.thumbnail:hover,
.thumbnail.is-selected {
border-color: var(--color-accent);
}
text-align: center;
.thumbnail:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 3px;
box-shadow:
0 0 0 6px var(--color-accent-strong),
0 0 0 1px rgb(32 33 36 / 16%);
}
@include portrait {
width: 100%;
}
.thumbnail--portrait {
inline-size: 8vmin;
}
@include landscape {
width: 101vh;
transform: rotate(-90deg);
}
}
}
.thumbnail--landscape {
inline-size: 12vmin;
}
main#images {
flex: 1;
display: flex;
justify-content: space-between;
.thumbnail picture,
.thumbnail img {
inline-size: 100%;
}
@include portrait {
flex-direction: column;
}
.frame {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
padding: var(--space-m);
}
.cluster {
display: flex;
justify-content: space-between;
.frame-content {
min-width: 0;
min-height: 0;
display: grid;
place-items: center;
}
img {
cursor: pointer;
}
.frame-figure {
min-width: 0;
margin: 0;
display: grid;
justify-items: center;
gap: var(--space-s);
}
&#portraits > img {
@include portrait {
height: 12vmin;
width: auto;
}
@include landscape {
width: 8vmin;
}
}
.frame-figure picture {
display: grid;
place-items: center;
min-width: 0;
}
&#landscapes > img {
@include portrait {
height: 8vmin;
width: auto;
}
@include landscape {
width: 12vmin;
}
}
.frame-figure img {
inline-size: auto;
max-inline-size: 100%;
max-block-size: calc(100dvh - 5rem);
border: var(--border-width) solid var(--color-text);
background: var(--color-text);
object-fit: contain;
box-shadow: 0 0 0 1px rgb(32 33 36 / 16%);
}
img {
@include portrait {
margin: 0 $small-margin;
}
}
.frame-figure figcaption {
max-inline-size: min(42rem, 100%);
color: var(--color-ink);
font-size: 0.95rem;
line-height: 1.35;
text-align: center;
}
@include portrait {
width: 100%;
overflow: auto;
margin: $small-margin 0;
padding: $small-margin 0;
object-fit: contain;
.slideshow-toggle {
display: none;
justify-self: end;
margin-block-start: var(--space-s);
border: 0;
border-radius: 4px;
padding: 0.45rem 0.75rem;
background: var(--color-accent);
color: var(--color-text);
font: inherit;
cursor: pointer;
}
:first-child {
margin-left: 2 * $small-margin;
}
.js .slideshow-toggle {
display: block;
}
&:last-child {
margin-right: 2 * $small-margin !important;
}
}
.slideshow-toggle:hover {
background: var(--color-accent-strong);
}
@include landscape {
flex-direction: column;
margin: $normal-margin;
img {
margin: $small-margin 0;
}
:first-child {
margin-top: 0;
}
:last-child {
margin-bottom: 0;
}
}
}
#frame-container {
@include center-children;
margin: 0 $border-width;
flex: 1;
@include landscape {
}
@include portrait {
position: relative;
}
img {
height: auto;
width: auto;
top: 50%;
transform: translateY(-50%) translateX(-50%);
@include portrait {
position: absolute;
box-sizing: border-box;
max-width: 100% !important; // undo js
max-height: 100%;
left: 50% !important; // undo js
}
@include landscape {
position: fixed;
max-height: 90vh;
}
}
}
}
@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);
}
.site-header {
padding: var(--space-s) var(--space-m);
}
.site-header h1 {
writing-mode: horizontal-tb;
transform: none;
font-size: 2rem;
}
.gallery {
min-height: 0;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) auto;
}
.rail {
max-height: none;
min-width: 0;
overflow: auto hidden;
flex-direction: row;
justify-content: flex-start;
padding: var(--space-xs) var(--space-s);
}
.rail--portraits {
grid-row: 1;
}
.rail--landscapes {
grid-row: 3;
}
.thumbnail {
inline-size: auto;
}
.thumbnail--portrait img {
block-size: 12vmin;
inline-size: auto;
}
.thumbnail--landscape img {
block-size: 8vmin;
inline-size: auto;
}
.frame {
grid-row: 2;
padding: var(--space-s);
}
.frame-figure img {
max-block-size: calc(100dvh - 12rem);
}
}

197
src/photo-catalog.json Normal file
View file

@ -0,0 +1,197 @@
[
{
"file": "_DSC1190-Edit.jpg",
"alt": "Portrait of a man in a burgundy sleeveless shirt standing with folded arms above a city view.",
"caption": "Portrait above the city"
},
{
"file": "_DSC1484.jpg",
"alt": "A man smiling while drying a plate in a kitchen.",
"caption": "Kitchen portrait"
},
{
"file": "_DSC1834.jpg",
"alt": "A person sitting near dark rocks while waves crash into a sea cave.",
"caption": "Watching the surf"
},
{
"file": "_DSC2313.jpg",
"alt": "Portrait of a man in a teal coat standing among autumn leaves.",
"caption": "Autumn portrait"
},
{
"file": "_DSC2362.jpg",
"alt": "Portrait of a man with glasses leaning on a railing in a park.",
"caption": "Park portrait"
},
{
"file": "_DSC2674.jpg",
"alt": "A person in a hooded jacket walking down a narrow city street.",
"caption": "City walk"
},
{
"file": "_DSC8718-Edit.jpg",
"alt": "Defocused city lights glowing across a dark night skyline.",
"caption": "Night lights"
},
{
"file": "adam.jpg",
"alt": "A man standing against a dark background in a black graphic T-shirt.",
"caption": "Adam"
},
{
"file": "dorka-1.jpg",
"alt": "A woman in a white knit hat sitting outdoors on an autumn day.",
"caption": "Dorka"
},
{
"file": "kezdetazarovonalbol.jpg",
"alt": "A small plant stem silhouetted against warm yellow and orange light.",
"caption": "Small stem in warm light"
},
{
"file": "nap-2.jpg",
"alt": "The sun setting behind a mountain ridge over a field.",
"caption": "Sunset field"
},
{
"file": "nyomtatas-8.jpg",
"alt": "Close-up of ice crystals and frozen texture in blue light.",
"caption": "Ice crystals"
},
{
"file": "nyomtatas-10.jpg",
"alt": "Warm abstract light passing through dried plants.",
"caption": "Dried plants in light"
},
{
"file": "web-1-2.jpg",
"alt": "A city panorama seen through a coin-operated viewing telescope.",
"caption": "City lookout"
},
{
"file": "web-1.jpg",
"alt": "Black and white stage scene with dancers performing before an audience.",
"caption": "Stage performance"
},
{
"file": "web-2-2.jpg",
"alt": "Two zebras walking across a black and white landscape.",
"caption": "Zebras"
},
{
"file": "web-2.jpg",
"alt": "Coastal town and hillside beside a blue sea under a bright sky.",
"caption": "Coastal town"
},
{
"file": "web-3-2.jpg",
"alt": "A woman walking away on a beach while carrying a bag.",
"caption": "Beach walk"
},
{
"file": "web-3.jpg",
"alt": "Black and white lake scene with a small boat under a bright sun.",
"caption": "Boat on the lake"
},
{
"file": "web-4-2.jpg",
"alt": "Black and white seascape with waves rolling toward weathered posts.",
"caption": "Sea posts"
},
{
"file": "web-5.jpg",
"alt": "Candles glowing in the foreground with soft golden lights behind them.",
"caption": "Candles"
},
{
"file": "web-6.jpg",
"alt": "Aerial view over a city and river beneath broken clouds.",
"caption": "City and river"
},
{
"file": "web-7.jpg",
"alt": "The Eiffel Tower lit at night against a dark sky.",
"caption": "Eiffel Tower at night"
},
{
"file": "web-8.jpg",
"alt": "Bare winter trees fading into a pale snowy background.",
"caption": "Winter trees"
},
{
"file": "web-10.jpg",
"alt": "A person in a dark jacket sitting on a bench and looking over water.",
"caption": "Looking across the water"
},
{
"file": "web-11.jpg",
"alt": "Black and white close-up portrait of a dog looking into the camera.",
"caption": "Dog portrait"
},
{
"file": "web-12.jpg",
"alt": "Two people sitting by water under a vivid orange sunset.",
"caption": "Sunset by the water"
},
{
"file": "web-13.jpg",
"alt": "Portrait of a woman standing beside a tree in green summer light.",
"caption": "Tree portrait"
},
{
"file": "web-14.jpg",
"alt": "Golden sunlight reflecting on rippled water.",
"caption": "Sunlit ripples"
},
{
"file": "web-17.jpg",
"alt": "A small green plant shoot backlit by bright sunlight.",
"caption": "Backlit sprout"
},
{
"file": "web-18.jpg",
"alt": "Birds flying around pale stone sculpture details.",
"caption": "Birds and stone"
},
{
"file": "web-19.jpg",
"alt": "A bridge at night with blue lights and long yellow light trails.",
"caption": "Bridge light trails"
},
{
"file": "web-20.jpg",
"alt": "A seated man in a dark coat photographed at night with city lights behind him.",
"caption": "Night portrait"
},
{
"file": "web-22.jpg",
"alt": "Silhouettes seen through large windows during a pink and blue sunset.",
"caption": "Window silhouettes"
},
{
"file": "web-23.jpg",
"alt": "Tall clock tower and stone architecture with a bird flying nearby.",
"caption": "Clock tower"
},
{
"file": "web-24.jpg",
"alt": "Portrait of a woman at night with bridge lights behind her.",
"caption": "Bridge portrait"
},
{
"file": "web-25.jpg",
"alt": "Portrait of a smiling man at night with blurred city lights behind him.",
"caption": "Smiling night portrait"
},
{
"file": "web-26.jpg",
"alt": "Tall bridge lamps receding into a pale sky.",
"caption": "Bridge lamps"
},
{
"file": "web-42.jpg",
"alt": "Warm sunset over a grassy field with low hills in the distance.",
"caption": "Sunset hills"
}
]

28
src/photo-types.ts Normal file
View file

@ -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;
};

129
src/photos.test.ts Normal file
View file

@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest';
import { PhotoGallery, wrapIndex } from './photos';
import type { Photo } from './photo-types';
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)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
window.HTMLElement.prototype.scrollIntoView = vi.fn();
document.body.innerHTML = `
<main id="gallery">
<a href="static/photos/one.jpg" data-photo-id="one">One</a>
<a href="static/photos/two.jpg" data-photo-id="two">Two</a>
</main>
<section id="frame"></section>
<button id="toggle" type="button">Play</button>
`;
const gallery = document.querySelector<HTMLElement>('#gallery');
const frame = document.querySelector<HTMLElement>('#frame');
const toggle = document.querySelector<HTMLButtonElement>('#toggle');
if (!gallery || !frame || !toggle) {
throw new Error('Test DOM failed to initialize.');
}
return {
gallery,
frame,
toggle,
photos: [photo('one', 'First photo'), photo('two', 'Second photo')],
};
};
describe('wrapIndex', () => {
it('wraps backward to the last item', () => {
expect(wrapIndex(-1, 4)).toBe(3);
});
it('wraps forward to the first item', () => {
expect(wrapIndex(4, 4)).toBe(0);
});
it('rejects empty collections', () => {
expect(() => wrapIndex(0, 0)).toThrow('without items');
});
});
describe('PhotoGallery', () => {
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<HTMLAnchorElement>('[data-photo-id="two"]')
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
);
expect(frame.querySelector('img')?.alt).toBe('Second photo');
expect(
gallery
.querySelector('[data-photo-id="two"]')
?.getAttribute('aria-current')
).toBe('true');
});
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', {
key: 'ArrowLeft',
bubbles: true,
cancelable: true,
})
);
expect(frame.querySelector('img')?.alt).toBe('Second photo');
});
});

View file

@ -1,109 +1,273 @@
import { ResponsiveImage } from './model/responsive-image';
import { createImage } from './helper/create-image';
import type { ImageSource, Photo } from './photo-types';
export class Photos {
private readonly images: Array<HTMLImageElement>;
private _selectedId: number;
private timerId: NodeJS.Timeout = null;
private shouldNotChange = false;
const AUTO_ADVANCE_DELAY_MS = 7000;
const FRAME_SIZES =
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)';
public constructor(
images: Array<ResponsiveImage>,
private readonly landscapesContainer: HTMLElement,
private readonly portraitsContainer: HTMLElement,
private readonly pictureFrame: HTMLImageElement
) {
this.images = this.convertImages(images);
this.placeImages();
this.addEventListeners();
this.startAutoChange();
this.selectedId = 0;
type GalleryOptions = {
readonly photos: readonly Photo[];
readonly gallery: HTMLElement;
readonly frame: HTMLElement;
readonly toggle: HTMLButtonElement;
readonly autoAdvance?: boolean;
};
type SelectOptions = {
readonly focusThumbnail?: boolean;
readonly pause?: boolean;
};
export const requiredElement = <T extends Element>(
selector: string,
constructor: { new (...args: never[]): T }
): T => {
const element = document.querySelector(selector);
if (!(element instanceof constructor)) {
throw new Error(`Required element not found: ${selector}`);
}
private get selectedId(): number {
return this._selectedId;
return element;
};
export const wrapIndex = (value: number, count: number): number => {
if (count <= 0) {
throw new Error('Cannot wrap an index without items.');
}
private set selectedId(value: number) {
if (value < 0) {
value += this.images.length - 1;
return ((value % count) + count) % count;
};
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 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';
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 = photo.caption;
figure.append(picture, caption);
return figure;
};
export class PhotoGallery {
private selectedIndex = 0;
private timerId: number | null = null;
private readonly thumbnails: HTMLAnchorElement[];
private readonly reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
public constructor(private readonly options: GalleryOptions) {
if (options.photos.length === 0) {
throw new Error('The gallery needs at least one photo.');
}
this._selectedId = value % this.images.length;
this.loadToFrame(this.images[this.selectedId]);
this.images[this.selectedId].focus();
this.shouldNotChange = true;
}
private convertImages(images: Array<ResponsiveImage>) {
let portraits = 0; // evens
let landscapes = 1; // odds
const imgs = images.map((image, i) =>
createImage(
image,
'a photo',
Photos.isLandscape(image) ? (landscapes += 2) : (portraits += 2)
)
this.thumbnails = this.options.photos.map((photo) =>
this.thumbnailFor(photo)
);
this.addEventListeners();
this.select(0);
return imgs.sort((a, b) => a.tabIndex - b.tabIndex);
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
this.startAutoAdvance();
} else {
this.updateToggle(false);
}
}
private placeImages() {
this.images.forEach(img => {
if (Photos.isLandscape(img)) {
img.classList.add('landscape');
this.landscapesContainer.appendChild(img);
} else {
img.classList.add('portrait');
this.portraitsContainer.appendChild(img);
private addEventListeners(): void {
this.options.gallery.addEventListener('click', (event) => {
const thumbnail = (event.target as Element).closest<HTMLAnchorElement>(
'[data-photo-id]'
);
if (!thumbnail) {
return;
}
});
}
private addEventListeners() {
window.document.addEventListener('keydown', e => {
switch (e.key) {
event.preventDefault();
this.selectById(thumbnail.dataset.photoId, { pause: true });
});
this.options.gallery.addEventListener('keydown', (event) => {
if (!this.options.gallery.contains(event.target as Node)) {
return;
}
switch (event.key) {
case 'ArrowLeft':
this.selectedId--;
event.preventDefault();
this.select(this.selectedIndex - 1, {
focusThumbnail: true,
pause: true,
});
break;
case 'ArrowRight':
this.selectedId++;
event.preventDefault();
this.select(this.selectedIndex + 1, {
focusThumbnail: true,
pause: true,
});
break;
case 'Home':
event.preventDefault();
this.select(0, { focusThumbnail: true, pause: true });
break;
case 'End':
event.preventDefault();
this.select(this.options.photos.length - 1, {
focusThumbnail: true,
pause: true,
});
break;
case 'Tab':
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
});
window.document.addEventListener(
'scroll',
() => (this.shouldNotChange = true)
this.options.gallery.addEventListener('pointerdown', () =>
this.pauseAutoAdvance()
);
this.options.gallery.addEventListener(
'wheel',
() => this.pauseAutoAdvance(),
{ passive: true }
);
this.options.gallery.addEventListener(
'touchstart',
() => this.pauseAutoAdvance(),
{ passive: true }
);
this.images.forEach((image, i) =>
image.addEventListener('click', () => (this.selectedId = i))
);
}
private startAutoChange() {
this.timerId = setInterval(() => {
if (!this.shouldNotChange) {
this.selectedId++;
this.options.toggle.addEventListener('click', () => {
if (this.timerId === null) {
this.startAutoAdvance();
} else {
this.pauseAutoAdvance();
}
this.shouldNotChange = false;
}, 7000);
});
this.reducedMotion.addEventListener('change', () => {
if (this.reducedMotion.matches) {
this.pauseAutoAdvance();
}
});
}
private loadToFrame(img: HTMLImageElement) {
this.pictureFrame.src = img.src;
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 static isLandscape(
image: HTMLImageElement | ResponsiveImage
): boolean {
return image.width > image.height;
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.options.photos.length);
const photo = this.options.photos[this.selectedIndex];
if (!photo) {
throw new Error('Selected photo is missing.');
}
this.options.frame.replaceChildren(createFrameFigure(photo));
this.updateSelectedThumbnail(photo, options.focusThumbnail ?? false);
if (options.pause) {
this.pauseAutoAdvance();
}
}
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 {
thumbnail.removeAttribute('aria-current');
}
thumbnail.classList.toggle('is-selected', selected);
if (selected && shouldFocus) {
thumbnail.focus({ preventScroll: true });
thumbnail.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
}
}
}
private startAutoAdvance(): void {
this.pauseAutoAdvance(false);
if (this.reducedMotion.matches) {
this.updateToggle(false);
return;
}
this.timerId = window.setInterval(() => {
this.select(this.selectedIndex + 1);
}, AUTO_ADVANCE_DELAY_MS);
this.updateToggle(true);
}
private pauseAutoAdvance(updateToggle = true): void {
if (this.timerId !== null) {
window.clearInterval(this.timerId);
this.timerId = null;
}
if (updateToggle) {
this.updateToggle(false);
}
}
private updateToggle(isRunning: boolean): void {
this.options.toggle.setAttribute('aria-pressed', String(isRunning));
this.options.toggle.textContent = isRunning ? 'Pause' : 'Play';
}
}

9
stylelint.config.js Normal file
View file

@ -0,0 +1,9 @@
export default {
extends: ['stylelint-config-standard-scss'],
rules: {
'selector-id-pattern': null,
'selector-class-pattern':
'^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*)?$',
'scss/dollar-variable-pattern': null,
},
};

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.test.ts'],
},
});

View file

@ -1,174 +0,0 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const TerserJSPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
const Sharp = require('responsive-loader/sharp');
const Sass = require('sass');
const isProduction = process.env.NODE_ENV === 'production';
module.exports = {
watchOptions: {
ignored: /node_modules/,
},
devServer: {
host: '0.0.0.0',
disableHostCheck: true,
},
optimization: {
minimizer: [
new TerserJSPlugin({
sourceMap: !isProduction,
}),
new OptimizeCSSAssetsPlugin({}),
],
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
xhtml: true,
template: './src/index.html',
minify: {
collapseWhitespace: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
},
inlineSource: '.(js|css)$',
}),
new HtmlWebpackInlineSourcePlugin(),
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[id].[contenthash].css',
}),
],
entry: {
index: './src/index.ts',
},
module: {
rules: [
{
test: /\.(jpe?g|png)$/i,
loader: 'responsive-loader',
options: {
adapter: Sharp,
quality: 90,
outputPath: 'static/',
sizes: [300, 800, 1200, 2000],
placeholder: false,
},
},
{
test: /\.(webm|mp4|gif)$/i,
use: [
{
loader: 'file-loader',
query: {
outputPath: 'static/',
},
},
{
loader: 'image-webpack-loader',
options: {
disable: !isProduction,
mozjpeg: {
progressive: true,
quality: 65,
},
optipng: {
enabled: true,
},
pngquant: {
quality: [0.65, 0.9],
speed: 4,
},
gifsicle: {
interlaced: false,
},
webp: {
quality: 65,
},
},
},
],
},
{
test: /\.svg$/,
loader: 'svg-url-loader',
options: {
limit: 10 * 1024,
noquotes: true,
},
},
{
test: /\.(pdf)$/i,
use: {
loader: 'file-loader',
query: {
outputPath: 'static/',
name: '[name].[ext]',
},
},
},
{
test: /no-change.*(ico|html|txt)$/i,
use: {
loader: 'file-loader',
query: {
outputPath: '/',
name: '[name].[ext]',
},
},
},
{
test: /\.scss$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
{
loader: 'resolve-url-loader',
options: {
keepQuery: true,
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
implementation: Sass,
},
},
],
},
{
test: /\.(woff2?|ttf|eot|svg)(?:[?#].+)?$/,
use: {
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'static/fonts/',
},
},
include: /fonts/,
},
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.ts', '.js'],
},
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
},
};