Enhance photos page with multiple view modes
- Add support for masonry, single, two-column, and horizontal view modes - Implement wide width mode (1100px) for more columns - Add padding adjustments for horizontal scroll mode - Load all photos automatically in horizontal mode - Preserve scroll position on navigation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
26ef48fa95
commit
b0ecd54243
1 changed files with 189 additions and 40 deletions
|
|
@ -1,12 +1,19 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MasonryPhotoGrid from '$components/MasonryPhotoGrid.svelte'
|
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 LoadingSpinner from '$components/admin/LoadingSpinner.svelte'
|
||||||
import ViewModeSelector from '$components/ViewModeSelector.svelte'
|
import ViewModeSelector from '$components/ViewModeSelector.svelte'
|
||||||
|
import type { ViewMode } from '$components/ViewModeSelector.svelte'
|
||||||
import { InfiniteLoader, LoaderState } from 'svelte-infinite'
|
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 { goto } from '$app/navigation'
|
||||||
|
import { browser } from '$app/environment'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
import type { PhotoItem } from '$lib/types/photos'
|
import type { PhotoItem } from '$lib/types/photos'
|
||||||
|
import type { Snapshot } from './$types'
|
||||||
|
|
||||||
const { data }: { data: PageData } = $props()
|
const { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
|
@ -18,6 +25,12 @@
|
||||||
let currentOffset = $state(data.pagination?.limit || 20)
|
let currentOffset = $state(data.pagination?.limit || 20)
|
||||||
let containerWidth = $state<'normal' | 'wide'>('normal')
|
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 : 'masonry'
|
||||||
|
)
|
||||||
|
|
||||||
// Track loaded photo IDs to prevent duplicates
|
// Track loaded photo IDs to prevent duplicates
|
||||||
let loadedPhotoIds = $state(new Set(data.photoItems?.map((item) => item.id) || []))
|
let loadedPhotoIds = $state(new Set(data.photoItems?.map((item) => item.id) || []))
|
||||||
|
|
||||||
|
|
@ -26,6 +39,53 @@
|
||||||
|
|
||||||
// Error message for retry display
|
// Error message for retry display
|
||||||
let lastError = $state<string>('')
|
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 === 'masonry') {
|
||||||
|
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
|
// Load more photos
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
|
|
@ -68,9 +128,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize loader state based on initial data
|
// Initialize loader state based on initial data
|
||||||
|
let hasInitialized = false
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!data.pagination?.hasMore) {
|
if (!hasInitialized) {
|
||||||
loaderState.complete()
|
hasInitialized = true
|
||||||
|
if (!data.pagination?.hasMore) {
|
||||||
|
loaderState.complete()
|
||||||
|
} else if (viewMode === 'horizontal') {
|
||||||
|
// Load all photos for horizontal mode on initial load
|
||||||
|
loadAllPhotos()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -82,6 +149,37 @@
|
||||||
url: pageUrl
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -102,7 +200,11 @@
|
||||||
<link rel="canonical" href={metaTags.other.canonical} />
|
<link rel="canonical" href={metaTags.other.canonical} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="photos-container" class:wide={containerWidth === 'wide'}>
|
<div
|
||||||
|
class="photos-container"
|
||||||
|
class:wide={containerWidth === 'wide'}
|
||||||
|
class:horizontal-mode={viewMode === 'horizontal'}
|
||||||
|
>
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-message">
|
<div class="error-message">
|
||||||
|
|
@ -118,48 +220,68 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ViewModeSelector
|
<ViewModeSelector
|
||||||
|
mode={viewMode}
|
||||||
width={containerWidth}
|
width={containerWidth}
|
||||||
onWidthChange={(width) => containerWidth = width}
|
onModeChange={handleViewModeChange}
|
||||||
|
onWidthChange={(width) => (containerWidth = width)}
|
||||||
/>
|
/>
|
||||||
<MasonryPhotoGrid photoItems={allPhotoItems} />
|
|
||||||
|
|
||||||
<InfiniteLoader
|
<div class="grid-container" class:full-width={viewMode === 'horizontal'}>
|
||||||
{loaderState}
|
{#if viewMode === 'masonry'}
|
||||||
triggerLoad={loadMore}
|
<MasonryPhotoGrid photoItems={allPhotoItems} />
|
||||||
intersectionOptions={{ rootMargin: '0px 0px 200px 0px' }}
|
{:else if viewMode === 'single'}
|
||||||
>
|
<SingleColumnPhotoGrid photoItems={allPhotoItems} />
|
||||||
<!-- Empty content since we're rendering the grid above -->
|
{:else if viewMode === 'two-column'}
|
||||||
<div style="height: 1px;"></div>
|
<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>
|
||||||
|
|
||||||
{#snippet loading()}
|
{#if viewMode !== 'horizontal'}
|
||||||
<div class="loading-container">
|
<InfiniteLoader
|
||||||
<LoadingSpinner size="medium" text="Loading more photos..." />
|
{loaderState}
|
||||||
</div>
|
triggerLoad={loadMore}
|
||||||
{/snippet}
|
intersectionOptions={{ rootMargin: '0px 0px 200px 0px' }}
|
||||||
|
>
|
||||||
|
<!-- Empty content since we're rendering the grid above -->
|
||||||
|
<div style="height: 1px;"></div>
|
||||||
|
|
||||||
{#snippet error()}
|
{#snippet loading()}
|
||||||
<div class="error-retry">
|
<div class="loading-container">
|
||||||
<p class="error-text">{lastError || 'Failed to load photos'}</p>
|
<LoadingSpinner size="medium" text="Loading more photos..." />
|
||||||
<button
|
</div>
|
||||||
class="retry-button"
|
{/snippet}
|
||||||
onclick={() => {
|
|
||||||
lastError = ''
|
|
||||||
loaderState.reset()
|
|
||||||
loadMore()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet noData()}
|
{#snippet error()}
|
||||||
<div class="end-message">
|
<div class="error-retry">
|
||||||
<p>You've reached the end</p>
|
<p class="error-text">{lastError || 'Failed to load photos'}</p>
|
||||||
</div>
|
<button
|
||||||
{/snippet}
|
class="retry-button"
|
||||||
</InfiniteLoader>
|
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -172,7 +294,23 @@
|
||||||
transition: max-width 0.3s ease;
|
transition: max-width 0.3s ease;
|
||||||
|
|
||||||
&.wide {
|
&.wide {
|
||||||
max-width: 900px;
|
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) {
|
:global(.view-mode-selector) {
|
||||||
|
|
@ -229,6 +367,17 @@
|
||||||
margin-top: $unit-4x;
|
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 {
|
.end-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: $unit-6x 0;
|
padding: $unit-6x 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue