jedmund-svelte/src/routes/photos/+page.svelte

428 lines
10 KiB
Svelte

<script lang="ts">
import MasonryPhotoGrid from '$components/MasonryPhotoGrid.svelte'
import SingleColumnPhotoGrid from '$components/SingleColumnPhotoGrid.svelte'
import TwoColumnPhotoGrid from '$components/TwoColumnPhotoGrid.svelte'
import HorizontalScrollPhotoGrid from '$components/HorizontalScrollPhotoGrid.svelte'
import LoadingSpinner from '$components/admin/LoadingSpinner.svelte'
import ViewModeSelector from '$components/ViewModeSelector.svelte'
import type { ViewMode } from '$components/ViewModeSelector.svelte'
import { InfiniteLoader, LoaderState } from 'svelte-infinite'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { browser } from '$app/environment'
import type { PageData } from './$types'
import type { PhotoItem } from '$lib/types/photos'
import type { Snapshot } from './$types'
const { data }: { data: PageData } = $props()
// 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)
let containerWidth = $state<'normal' | 'wide'>('normal')
// Initialize view mode from URL or default
const urlMode = $page.url.searchParams.get('view') as ViewMode
let viewMode = $state<ViewMode>(
urlMode && ['masonry', 'single', 'two-column', 'horizontal'].includes(urlMode) ? urlMode : 'two-column'
)
// 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>('')
let isLoadingAll = $state(false)
// Update URL when view mode changes
async function handleViewModeChange(mode: ViewMode) {
viewMode = mode
if (browser) {
const url = new URL($page.url)
if (mode === 'two-column') {
url.searchParams.delete('view')
} else {
url.searchParams.set('view', mode)
}
goto(url.toString(), { replaceState: true, keepFocus: true })
// Load all remaining photos for horizontal mode
if (mode === 'horizontal' && data.pagination?.hasMore && !isLoadingAll) {
loadAllPhotos()
}
}
}
// Load all photos for horizontal mode
async function loadAllPhotos() {
if (isLoadingAll) return
isLoadingAll = true
try {
while (currentOffset < (data.pagination?.total || Infinity)) {
const response = await fetch(`/api/photos?limit=50&offset=${currentOffset}`)
if (!response.ok) break
const result = await response.json()
const newItems = (result.photoItems || []).filter(
(item: PhotoItem) => !loadedPhotoIds.has(item.id)
)
newItems.forEach((item: PhotoItem) => loadedPhotoIds.add(item.id))
allPhotoItems = [...allPhotoItems, ...newItems]
currentOffset += result.pagination?.limit || 50
if (!result.pagination?.hasMore) break
}
} finally {
isLoadingAll = false
loaderState.complete()
}
}
// 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
let hasInitialized = false
$effect(() => {
if (!hasInitialized) {
hasInitialized = true
if (!data.pagination?.hasMore) {
loaderState.complete()
} else if (viewMode === 'horizontal') {
// Load all photos for horizontal mode on initial load
loadAllPhotos()
}
}
})
// Generate metadata for photos page
const metaTags = $derived(
generateMetaTags({
title: 'Photos',
description: 'A collection of photography from travels, daily life, and creative projects.',
url: pageUrl
})
)
// Snapshot to preserve scroll position
export const snapshot: Snapshot<{
scrollY: number
horizontalScroll: number | undefined
}> = {
capture: () => {
if (!browser) return { scrollY: 0, horizontalScroll: undefined }
return {
scrollY: window.scrollY,
horizontalScroll: document.querySelector('.horizontal-scroll')?.scrollLeft
}
},
restore: (data) => {
if (!browser) return
// Small delay to ensure content is rendered
setTimeout(() => {
if (data.scrollY) {
window.scrollTo(0, data.scrollY)
}
if (data.horizontalScroll !== undefined) {
const element = document.querySelector('.horizontal-scroll')
if (element) {
element.scrollLeft = data.horizontalScroll
}
}
}, 10)
}
}
</script>
<svelte:head>
<title>{metaTags.title}</title>
<meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Canonical URL -->
<link rel="canonical" href={metaTags.other.canonical} />
</svelte:head>
<div
class="photos-container"
class:wide={containerWidth === 'wide'}
class:horizontal-mode={viewMode === 'horizontal'}
>
{#if error}
<div class="error-container">
<div class="error-message">
<h2>Unable to load photos</h2>
<p>{error}</p>
</div>
</div>
{:else if allPhotoItems.length === 0}
<div class="empty-container">
<div class="empty-message">
<h2>No photos yet</h2>
<p>Photos and albums will be added soon</p>
</div>
</div>
{:else}
<ViewModeSelector
mode={viewMode}
width={containerWidth}
onModeChange={handleViewModeChange}
onWidthChange={(width) => (containerWidth = width)}
/>
<div class="grid-container" class:full-width={viewMode === 'horizontal'}>
{#if viewMode === 'masonry'}
<MasonryPhotoGrid photoItems={allPhotoItems} />
{:else if viewMode === 'single'}
<SingleColumnPhotoGrid photoItems={allPhotoItems} />
{:else if viewMode === 'two-column'}
<TwoColumnPhotoGrid photoItems={allPhotoItems} />
{:else if viewMode === 'horizontal'}
<HorizontalScrollPhotoGrid photoItems={allPhotoItems} />
{#if isLoadingAll}
<div class="loading-more-indicator">
<LoadingSpinner size="small" text="Loading all photos..." />
</div>
{/if}
{/if}
</div>
{#if viewMode !== 'horizontal'}
<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>
<style lang="scss">
.photos-container {
width: 100%;
max-width: 700px;
margin: 0 auto;
padding: 0 $unit-3x;
transition: max-width 0.3s ease;
&.wide {
max-width: 1100px;
}
&.horizontal-mode {
max-width: none;
padding-left: 0;
padding-right: 0;
:global(.view-mode-selector) {
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
&.wide :global(.view-mode-selector) {
max-width: 1100px;
}
}
:global(.view-mode-selector) {
margin-bottom: $unit-3x;
position: sticky;
top: $unit-2x;
z-index: 10;
}
@include breakpoint('phone') {
padding: 0 $unit-2x;
box-sizing: border-box;
}
}
.error-container,
.empty-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.error-message,
.empty-message {
text-align: center;
max-width: 500px;
h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 $unit-2x;
color: $grey-10;
}
p {
margin: 0;
color: $grey-40;
line-height: 1.5;
}
}
.error-message {
h2 {
color: $red-60;
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100px;
margin-top: $unit-4x;
}
.loading-more-indicator {
position: fixed;
bottom: $unit-3x;
right: $unit-3x;
background: $grey-100;
padding: $unit-2x $unit-3x;
border-radius: $corner-radius-lg;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 20;
}
.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>