428 lines
10 KiB
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>
|