Add better masonry and infinite scrolling

This commit is contained in:
Justin Edmund 2025-06-16 09:58:07 +01:00
parent 47e9e300db
commit 6132c17a9b
6 changed files with 304 additions and 32 deletions

18
package-lock.json generated
View file

@ -58,6 +58,8 @@
"redis": "^4.7.0",
"sharp": "^0.34.2",
"steamapi": "^3.0.11",
"svelte-bricks": "^0.3.2",
"svelte-infinite": "^0.5.0",
"svelte-medium-image-zoom": "^0.2.6",
"svelte-portal": "^2.2.1",
"svelte-tiptap": "^2.1.0",
@ -7809,6 +7811,14 @@
"@types/estree": "^1.0.1"
}
},
"node_modules/svelte-bricks": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/svelte-bricks/-/svelte-bricks-0.3.2.tgz",
"integrity": "sha512-VKQdeXj0+iRV8U0BWl+5r3W/IzZiA51Z6Zjctj/AYd5PU9MwNraHzj63wzUataKxDXB2AX2vc/bb4LuqywY0/w==",
"dependencies": {
"svelte": "^5.27.3"
}
},
"node_modules/svelte-check": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.4.tgz",
@ -7901,6 +7911,14 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/svelte-infinite": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/svelte-infinite/-/svelte-infinite-0.5.0.tgz",
"integrity": "sha512-3ZomRQcQzg8VWtnqO4MvPC0Jt3hvh1wmC47t64BcI+8UXTl0FJYVfB7ky4d1NJ3mf/KZZa+hcIZJPnV9cOt8gQ==",
"peerDependencies": {
"svelte": "^5.0.0-0"
}
},
"node_modules/svelte-medium-image-zoom": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/svelte-medium-image-zoom/-/svelte-medium-image-zoom-0.2.6.tgz",

View file

@ -103,6 +103,8 @@
"redis": "^4.7.0",
"sharp": "^0.34.2",
"steamapi": "^3.0.11",
"svelte-bricks": "^0.3.2",
"svelte-infinite": "^0.5.0",
"svelte-medium-image-zoom": "^0.2.6",
"svelte-portal": "^2.2.1",
"svelte-tiptap": "^2.1.0",

View file

@ -0,0 +1,71 @@
<script lang="ts">
import Masonry from 'svelte-bricks'
import PhotoItem from './PhotoItem.svelte'
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
const {
photoItems,
albumSlug
}: {
photoItems: PhotoItemType[]
albumSlug?: string
} = $props()
// Responsive column configuration
// These values work well with our existing design
let minColWidth = 200 // Minimum column width in px
let maxColWidth = 400 // Maximum column width in px
let gap = 24 // Gap between items (equivalent to $unit-3x)
// On tablet/phone, we want larger minimum widths
let windowWidth = $state(0)
$effect(() => {
// Adjust column widths based on viewport
if (windowWidth < 768) {
// Phone: single column
minColWidth = windowWidth - 48 // Account for padding
maxColWidth = windowWidth - 48
} else if (windowWidth < 1024) {
// Tablet: 2 columns
minColWidth = 300
maxColWidth = 500
} else {
// Desktop: 3 columns
minColWidth = 200
maxColWidth = 400
}
})
// Ensure unique IDs for keyed blocks to prevent shifting
const getId = (item: PhotoItemType) => item.id
</script>
<svelte:window bind:innerWidth={windowWidth} />
<div class="masonry-container">
<Masonry
items={photoItems}
{minColWidth}
{maxColWidth}
{gap}
{getId}
animate={false}
duration={0}
class="photo-masonry"
>
{#snippet children({ item })}
<PhotoItem {item} {albumSlug} />
{/snippet}
</Masonry>
</div>
<style lang="scss">
.masonry-container {
width: 100%;
}
:global(.photo-masonry) {
width: 100%;
}
</style>

View file

@ -35,10 +35,8 @@ export const GET: RequestHandler = async (event) => {
}
}
}
},
orderBy: { createdAt: 'desc' },
skip: offset,
take: limit
}
// Remove orderBy to sort everything together later
})
// Fetch individual photos (marked for photography, not in any album)
@ -63,17 +61,50 @@ export const GET: RequestHandler = async (event) => {
createdAt: true,
photoPublishedAt: true,
exifData: true
},
orderBy: { photoPublishedAt: 'desc' },
skip: offset,
take: limit
}
// Remove orderBy to sort everything together later
})
// Helper function to extract date from EXIF data
const getPhotoDate = (media: any): Date => {
// Try to get date from EXIF data
if (media.exifData && typeof media.exifData === 'object') {
// Check for common EXIF date fields
const exif = media.exifData as any
const dateTaken = exif.DateTimeOriginal || exif.DateTime || exif.dateTaken
if (dateTaken) {
// Parse EXIF date format (typically "YYYY:MM:DD HH:MM:SS")
const parsedDate = new Date(dateTaken.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3'))
if (!isNaN(parsedDate.getTime())) {
return parsedDate
}
}
}
// Fallback to photoPublishedAt
if (media.photoPublishedAt) {
return new Date(media.photoPublishedAt)
}
// Final fallback to createdAt
return new Date(media.createdAt)
}
// Transform albums to PhotoAlbum format
const photoAlbums: PhotoAlbum[] = albums
.filter((album) => album.media.length > 0) // Only include albums with media
.map((album) => {
const firstMedia = album.media[0].media
// Find the most recent EXIF date from all photos in the album
let albumDate = new Date(album.createdAt)
for (const albumMedia of album.media) {
const mediaDate = getPhotoDate(albumMedia.media)
if (mediaDate > albumDate) {
albumDate = mediaDate
}
}
return {
id: `album-${album.id}`,
slug: album.slug,
@ -95,24 +126,14 @@ export const GET: RequestHandler = async (event) => {
width: albumMedia.media.width || 400,
height: albumMedia.media.height || 400
})),
createdAt: album.createdAt.toISOString()
createdAt: albumDate.toISOString()
}
})
// Transform individual media to Photo format
const photos: Photo[] = individualMedia.map((media) => {
// Extract date from EXIF data if available
let photoDate: string
if (media.exifData && typeof media.exifData === 'object' && 'dateTaken' in media.exifData) {
// Use EXIF date if available
photoDate = media.exifData.dateTaken as string
} else if (media.photoPublishedAt) {
// Fall back to published date
photoDate = media.photoPublishedAt.toISOString()
} else {
// Fall back to created date
photoDate = media.createdAt.toISOString()
}
// Use the same helper function to get the photo date
const photoDate = getPhotoDate(media)
return {
id: `media-${media.id}`,
@ -121,27 +142,32 @@ export const GET: RequestHandler = async (event) => {
caption: media.photoCaption || undefined,
width: media.width || 400,
height: media.height || 400,
createdAt: photoDate
createdAt: photoDate.toISOString()
}
})
// Combine albums and individual photos
const photoItems: PhotoItem[] = [...photoAlbums, ...photos]
let allPhotoItems: PhotoItem[] = [...photoAlbums, ...photos]
// Sort by creation date (both albums and photos now have createdAt)
photoItems.sort((a, b) => {
// Newest first (reverse chronological)
allPhotoItems.sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt) : new Date()
const dateB = b.createdAt ? new Date(b.createdAt) : new Date()
return dateB.getTime() - dateA.getTime()
})
// Apply pagination after sorting
const totalItems = allPhotoItems.length
const paginatedItems = allPhotoItems.slice(offset, offset + limit)
const response = {
photoItems,
photoItems: paginatedItems,
pagination: {
total: photoItems.length,
total: totalItems,
limit,
offset,
hasMore: photoItems.length === limit // Simple check, could be more sophisticated
hasMore: offset + limit < totalItems
}
}

View file

@ -1,14 +1,76 @@
<script lang="ts">
import PhotoGrid from '$components/PhotoGrid.svelte'
import MasonryPhotoGrid from '$components/MasonryPhotoGrid.svelte'
import LoadingSpinner from '$components/admin/LoadingSpinner.svelte'
import { InfiniteLoader, LoaderState } from 'svelte-infinite'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types'
import type { PhotoItem } from '$lib/types/photos'
const { data }: { data: PageData } = $props()
const photoItems = $derived(data.photoItems || [])
// Initialize loader state
const loaderState = new LoaderState()
// Initialize state with server-side data
let allPhotoItems = $state<PhotoItem[]>(data.photoItems || [])
let currentOffset = $state(data.pagination?.limit || 20)
// Track loaded photo IDs to prevent duplicates
let loadedPhotoIds = $state(new Set(data.photoItems?.map(item => item.id) || []))
const error = $derived(data.error)
const pageUrl = $derived($page.url.href)
// Error message for retry display
let lastError = $state<string>('')
// Load more photos
async function loadMore() {
try {
const response = await fetch(`/api/photos?limit=20&offset=${currentOffset}`)
if (!response.ok) {
throw new Error(`Failed to fetch photos: ${response.statusText}`)
}
const data = await response.json()
// Filter out duplicates
const newItems = (data.photoItems || []).filter(
(item: PhotoItem) => !loadedPhotoIds.has(item.id)
)
// Add new photo IDs to the set
newItems.forEach((item: PhotoItem) => loadedPhotoIds.add(item.id))
// Append new photos to existing list
allPhotoItems = [...allPhotoItems, ...newItems]
// Update pagination state
currentOffset += data.pagination?.limit || 20
// Update loader state
if (!data.pagination?.hasMore || newItems.length === 0) {
loaderState.complete()
} else {
loaderState.loaded()
}
// Clear any previous error
lastError = ''
} catch (err) {
console.error('Error loading more photos:', err)
lastError = err instanceof Error ? err.message : 'Failed to load more photos'
loaderState.error()
}
}
// Initialize loader state based on initial data
$effect(() => {
if (!data.pagination?.hasMore) {
loaderState.complete()
}
})
// Generate metadata for photos page
const metaTags = $derived(
@ -46,7 +108,7 @@
<p>{error}</p>
</div>
</div>
{:else if photoItems.length === 0}
{:else if allPhotoItems.length === 0}
<div class="empty-container">
<div class="empty-message">
<h2>No photos yet</h2>
@ -54,7 +116,44 @@
</div>
</div>
{:else}
<PhotoGrid {photoItems} />
<MasonryPhotoGrid photoItems={allPhotoItems} />
<InfiniteLoader
{loaderState}
triggerLoad={loadMore}
intersectionOptions={{ rootMargin: "0px 0px 200px 0px" }}
>
<!-- Empty content since we're rendering the grid above -->
<div style="height: 1px;"></div>
{#snippet loading()}
<div class="loading-container">
<LoadingSpinner size="medium" text="Loading more photos..." />
</div>
{/snippet}
{#snippet error()}
<div class="error-retry">
<p class="error-text">{lastError || 'Failed to load photos'}</p>
<button
class="retry-button"
onclick={() => {
lastError = '';
loaderState.reset();
loadMore();
}}
>
Try again
</button>
</div>
{/snippet}
{#snippet noData()}
<div class="end-message">
<p>You've reached the end</p>
</div>
{/snippet}
</InfiniteLoader>
{/if}
</div>
@ -103,4 +202,60 @@
color: $red-60;
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100px;
margin-top: $unit-4x;
}
.end-message {
text-align: center;
padding: $unit-6x 0;
p {
margin: 0;
color: $grey-50;
font-size: 1rem;
}
}
.error-retry {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-2x;
padding: $unit-4x $unit-2x;
margin-top: $unit-4x;
}
.error-text {
margin: 0;
color: $red-60;
font-size: 0.875rem;
text-align: center;
max-width: 300px;
}
.retry-button {
padding: $unit $unit-3x;
background-color: $primary-color;
color: white;
border: none;
border-radius: $unit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: darken($primary-color, 10%);
}
&:active {
transform: scale(0.98);
}
}
</style>

View file

@ -2,7 +2,7 @@ import type { PageLoad } from './$types'
export const load: PageLoad = async ({ fetch }) => {
try {
const response = await fetch('/api/photos?limit=50')
const response = await fetch('/api/photos?limit=20')
if (!response.ok) {
throw new Error('Failed to fetch photos')
}