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",
|
"redis": "^4.7.0",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"steamapi": "^3.0.11",
|
"steamapi": "^3.0.11",
|
||||||
|
"svelte-bricks": "^0.3.2",
|
||||||
|
"svelte-infinite": "^0.5.0",
|
||||||
"svelte-medium-image-zoom": "^0.2.6",
|
"svelte-medium-image-zoom": "^0.2.6",
|
||||||
"svelte-portal": "^2.2.1",
|
"svelte-portal": "^2.2.1",
|
||||||
"svelte-tiptap": "^2.1.0",
|
"svelte-tiptap": "^2.1.0",
|
||||||
|
|
@ -7809,6 +7811,14 @@
|
||||||
"@types/estree": "^1.0.1"
|
"@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": {
|
"node_modules/svelte-check": {
|
||||||
"version": "3.8.4",
|
"version": "3.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.4.tgz",
|
||||||
|
|
@ -7901,6 +7911,14 @@
|
||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/svelte-medium-image-zoom": {
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-medium-image-zoom/-/svelte-medium-image-zoom-0.2.6.tgz",
|
"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",
|
"redis": "^4.7.0",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"steamapi": "^3.0.11",
|
"steamapi": "^3.0.11",
|
||||||
|
"svelte-bricks": "^0.3.2",
|
||||||
|
"svelte-infinite": "^0.5.0",
|
||||||
"svelte-medium-image-zoom": "^0.2.6",
|
"svelte-medium-image-zoom": "^0.2.6",
|
||||||
"svelte-portal": "^2.2.1",
|
"svelte-portal": "^2.2.1",
|
||||||
"svelte-tiptap": "^2.1.0",
|
"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' },
|
// Remove orderBy to sort everything together later
|
||||||
skip: offset,
|
|
||||||
take: limit
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch individual photos (marked for photography, not in any album)
|
// Fetch individual photos (marked for photography, not in any album)
|
||||||
|
|
@ -63,17 +61,50 @@ export const GET: RequestHandler = async (event) => {
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
photoPublishedAt: true,
|
photoPublishedAt: true,
|
||||||
exifData: true
|
exifData: true
|
||||||
},
|
}
|
||||||
orderBy: { photoPublishedAt: 'desc' },
|
// Remove orderBy to sort everything together later
|
||||||
skip: offset,
|
|
||||||
take: limit
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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
|
// Transform albums to PhotoAlbum format
|
||||||
const photoAlbums: PhotoAlbum[] = albums
|
const photoAlbums: PhotoAlbum[] = albums
|
||||||
.filter((album) => album.media.length > 0) // Only include albums with media
|
.filter((album) => album.media.length > 0) // Only include albums with media
|
||||||
.map((album) => {
|
.map((album) => {
|
||||||
const firstMedia = album.media[0].media
|
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 {
|
return {
|
||||||
id: `album-${album.id}`,
|
id: `album-${album.id}`,
|
||||||
slug: album.slug,
|
slug: album.slug,
|
||||||
|
|
@ -95,24 +126,14 @@ export const GET: RequestHandler = async (event) => {
|
||||||
width: albumMedia.media.width || 400,
|
width: albumMedia.media.width || 400,
|
||||||
height: albumMedia.media.height || 400
|
height: albumMedia.media.height || 400
|
||||||
})),
|
})),
|
||||||
createdAt: album.createdAt.toISOString()
|
createdAt: albumDate.toISOString()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Transform individual media to Photo format
|
// Transform individual media to Photo format
|
||||||
const photos: Photo[] = individualMedia.map((media) => {
|
const photos: Photo[] = individualMedia.map((media) => {
|
||||||
// Extract date from EXIF data if available
|
// Use the same helper function to get the photo date
|
||||||
let photoDate: string
|
const photoDate = getPhotoDate(media)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `media-${media.id}`,
|
id: `media-${media.id}`,
|
||||||
|
|
@ -121,27 +142,32 @@ export const GET: RequestHandler = async (event) => {
|
||||||
caption: media.photoCaption || undefined,
|
caption: media.photoCaption || undefined,
|
||||||
width: media.width || 400,
|
width: media.width || 400,
|
||||||
height: media.height || 400,
|
height: media.height || 400,
|
||||||
createdAt: photoDate
|
createdAt: photoDate.toISOString()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Combine albums and individual photos
|
// 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)
|
// 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 dateA = a.createdAt ? new Date(a.createdAt) : new Date()
|
||||||
const dateB = b.createdAt ? new Date(b.createdAt) : new Date()
|
const dateB = b.createdAt ? new Date(b.createdAt) : new Date()
|
||||||
return dateB.getTime() - dateA.getTime()
|
return dateB.getTime() - dateA.getTime()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Apply pagination after sorting
|
||||||
|
const totalItems = allPhotoItems.length
|
||||||
|
const paginatedItems = allPhotoItems.slice(offset, offset + limit)
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
photoItems,
|
photoItems: paginatedItems,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: photoItems.length,
|
total: totalItems,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
hasMore: photoItems.length === limit // Simple check, could be more sophisticated
|
hasMore: offset + limit < totalItems
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,76 @@
|
||||||
<script lang="ts">
|
<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 { generateMetaTags } from '$lib/utils/metadata'
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
import type { PhotoItem } from '$lib/types/photos'
|
||||||
|
|
||||||
const { data }: { data: PageData } = $props()
|
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 error = $derived(data.error)
|
||||||
const pageUrl = $derived($page.url.href)
|
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
|
// Generate metadata for photos page
|
||||||
const metaTags = $derived(
|
const metaTags = $derived(
|
||||||
|
|
@ -46,7 +108,7 @@
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if photoItems.length === 0}
|
{:else if allPhotoItems.length === 0}
|
||||||
<div class="empty-container">
|
<div class="empty-container">
|
||||||
<div class="empty-message">
|
<div class="empty-message">
|
||||||
<h2>No photos yet</h2>
|
<h2>No photos yet</h2>
|
||||||
|
|
@ -54,7 +116,44 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -103,4 +202,60 @@
|
||||||
color: $red-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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { PageLoad } from './$types'
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch }) => {
|
export const load: PageLoad = async ({ fetch }) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/photos?limit=50')
|
const response = await fetch('/api/photos?limit=20')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch photos')
|
throw new Error('Failed to fetch photos')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue