Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions e2e/home.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,30 @@ async function waitForCardCount(page: Page, expected: number) {
}).toPass({ timeout: 10000 })
}

async function expectCardsNotToOverlap(page: Page) {
await expect(async () => {
const boxes = await page.locator('article.card').evaluateAll(cards =>
cards.map(card => {
const rect = card.getBoundingClientRect()
return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }
})
)

for (let i = 0; i < boxes.length; i += 1) {
for (let j = i + 1; j < boxes.length; j += 1) {
const a = boxes[i]
const b = boxes[j]
const overlaps =
a.left < b.right &&
a.right > b.left &&
a.top < b.bottom &&
a.bottom > b.top
expect(overlaps).toBe(false)
}
}
}).toPass({ timeout: 10000 })
}

test.describe('home page — with cards', () => {
test('inline search input filters cards by text', async ({ page }) => {
await page.goto('.')
Expand Down Expand Up @@ -116,6 +140,29 @@ test.describe('home page — with cards', () => {
expect(chipTexts.some(t => t.trim() === 'Adventure')).toBe(true)
}
})

test('masonry cards do not overlap on a cold uncached load', async ({ page }) => {
await page.route('**/*', route => {
route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } })
})

await page.goto('.', { waitUntil: 'networkidle' })
await waitForCards(page)
await page.locator('article.card img').evaluateAll(images =>
Promise.all(
images.map(image => {
const img = image as HTMLImageElement
if (img.complete) return Promise.resolve()
return new Promise<void>(resolve => {
img.addEventListener('load', () => resolve(), { once: true })
img.addEventListener('error', () => resolve(), { once: true })
})
})
)
)

await expectCardsNotToOverlap(page)
})
})

test.describe('home page — no cards', () => {
Expand Down
21 changes: 16 additions & 5 deletions src/lib/CardGrid.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,30 @@
}
}

function scheduleMeasure() {
requestAnimationFrame(measureSpans)
}

// Re-measure after each exhibits update so new cards get correct spans.
$effect(() => {
exhibits
if (config.cardLayout !== 'masonry') return
requestAnimationFrame(measureSpans)
scheduleMeasure()
})

// Re-measure on viewport resize so spans stay accurate as columns reflow.
// Re-measure when cards or images change size so cold image loads get correct
// masonry spans instead of overflowing into following grid rows.
$effect(() => {
if (config.cardLayout !== 'masonry' || !gridEl) return
const ro = new ResizeObserver(() => requestAnimationFrame(measureSpans))
ro.observe(gridEl)
return () => ro.disconnect()
const ro = new ResizeObserver(scheduleMeasure)
const cards = Array.from(gridEl.querySelectorAll<HTMLElement>('article.card'))
const images = Array.from(gridEl.querySelectorAll<HTMLImageElement>('img'))
cards.forEach(card => ro.observe(card))
images.forEach(image => image.addEventListener('load', scheduleMeasure, { once: true }))
return () => {
ro.disconnect()
images.forEach(image => image.removeEventListener('load', scheduleMeasure))
}
})
</script>

Expand Down
10 changes: 4 additions & 6 deletions src/lib/ExhibitCard.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Exhibit } from "./oddments.js";
import { asset, resolve } from "$app/paths";
import { resolve } from "$app/paths";
import { fallbackToRootAsset, resolveAssetPath } from "./assets.js";
import { config } from "./catalog.js";

let { exhibit }: { exhibit: Exhibit } = $props();
Expand All @@ -14,11 +15,7 @@
const author = $derived(toArray(exhibit.author).join(", "));
const orientation = $derived(exhibit.imageOrientation ?? config.imageOrientation);
const hasCover = $derived(Boolean(exhibit["cover-image"]));
const coverSrc = $derived(
exhibit["cover-image"]?.startsWith("/")
? asset(exhibit["cover-image"])
: (exhibit["cover-image"] ?? ""),
);
const coverSrc = $derived(resolveAssetPath(exhibit["cover-image"]));
const exhibitHref = $derived(resolve(`/exhibit/${exhibit.slug}/`))
</script>

Expand All @@ -38,6 +35,7 @@
alt={exhibit.name}
class="w-full h-auto"
loading="lazy"
onerror={fallbackToRootAsset}
/>
</a>
{/if}
Expand Down
29 changes: 29 additions & 0 deletions src/lib/assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { fallbackToRootAsset, resolveAssetPath } from './assets.js'
import { resetAssetBase, setAssetBase } from '../test/mocks/app-paths.js'

afterEach(() => {
resetAssetBase()
})

test('resolveAssetPath prefixes root-relative paths with the configured asset base', () => {
setAssetBase('/oddments')
expect(resolveAssetPath('/covers/game.webp')).toBe('/oddments/covers/game.webp')
})

test('resolveAssetPath leaves external and relative paths unchanged', () => {
setAssetBase('/oddments')
expect(resolveAssetPath('https://example.com/cover.webp')).toBe('https://example.com/cover.webp')
expect(resolveAssetPath('covers/game.webp')).toBe('covers/game.webp')
})

test('fallbackToRootAsset strips the configured asset base from failed images', () => {
setAssetBase('/oddments')
const img = document.createElement('img')
img.setAttribute('src', '/oddments/covers/game.webp')
const event = new Event('error')
Object.defineProperty(event, 'currentTarget', { value: img })

fallbackToRootAsset(event)

expect(img.getAttribute('src')).toBe('/covers/game.webp')
})
17 changes: 17 additions & 0 deletions src/lib/assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { asset } from '$app/paths'

export function resolveAssetPath(path: string | null | undefined): string {
if (!path) return ''
return path.startsWith('/') ? asset(path) : path
}

export function fallbackToRootAsset(event: Event) {
const element = event.currentTarget
if (!(element instanceof HTMLImageElement)) return

const src = element.getAttribute('src') ?? ''
const assetBase = asset('/').replace(/\/$/, '')
if (!assetBase || !src.startsWith(`${assetBase}/`)) return

element.src = src.slice(assetBase.length)
}
8 changes: 4 additions & 4 deletions src/routes/exhibit/[slug]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { asset, resolve } from '$app/paths'
import { resolve } from '$app/paths'
import { fallbackToRootAsset, resolveAssetPath } from '$lib/assets.js'
import Icon from '$lib/Icon.svelte'
const { data } = $props()
const exhibit = $derived(data.exhibit)
Expand All @@ -20,9 +21,7 @@
}

const hasCover = $derived(Boolean(exhibit['cover-image']))
const coverSrc = $derived(
exhibit['cover-image']?.startsWith('/') ? asset(exhibit['cover-image']) : (exhibit['cover-image'] ?? '')
)
const coverSrc = $derived(resolveAssetPath(exhibit['cover-image']))
const coverStyle = $derived(hasCover ? '' : `background: ${placeholderGradient(exhibit.name ?? exhibit.slug)};`)
const hasMetadataChips = $derived(
exhibit.category.length > 0 || Boolean(exhibit.genre) || Boolean(exhibit.license) || exhibit.tags.length > 0
Expand Down Expand Up @@ -79,6 +78,7 @@
src={coverSrc}
alt={exhibit.name ?? ''}
class="w-full rounded-container-token object-cover {classes}"
onerror={fallbackToRootAsset}
/>
{:else}
<div
Expand Down
13 changes: 12 additions & 1 deletion src/test/mocks/app-paths.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
export const base = ''
export const assets = ''

let assetBase = ''

export function setAssetBase(path: string) {
assetBase = path
}

export function resetAssetBase() {
assetBase = ''
}

export function resolve(path: string) {
return path
}

export function asset(file: string) {
return file
return `${assetBase}${file}`
}
Loading