Add better masonry and infinite scrolling
This commit is contained in:
parent
47e9e300db
commit
6132c17a9b
6 changed files with 304 additions and 32 deletions
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
71
src/lib/components/MasonryPhotoGrid.svelte
Normal file
71
src/lib/components/MasonryPhotoGrid.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,77 @@
|
|||
<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(
|
||||
generateMetaTags({
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue