refactor: consolidate photo grid components into unified PhotoGrid
- Create single PhotoGrid component with columns (1,2,3,auto) and masonry options - Remove 5 duplicate grid components - Update HorizontalScrollPhotoGrid to HorizontalPhotoScroll with Photo type - Add interactive test page for PhotoGrid - Update all pages to use new unified component - Use svelte-bricks for proper masonry layout - Single column always uses natural aspect ratios - Square thumbnails (object-fit: cover) for multi-column non-masonry layouts Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e92cc2393e
commit
5370ae020d
14 changed files with 512 additions and 871 deletions
|
|
@ -1,32 +1,26 @@
|
|||
<script lang="ts">
|
||||
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||
import { isAlbum } from '$lib/types/photos'
|
||||
import type { Photo } from '$lib/types/photos'
|
||||
|
||||
const {
|
||||
photoItems,
|
||||
albumSlug
|
||||
}: {
|
||||
photoItems: PhotoItemType[]
|
||||
albumSlug?: string
|
||||
} = $props()
|
||||
interface Props {
|
||||
photos: Photo[]
|
||||
showCaptions?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
photos = [],
|
||||
showCaptions = true
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="horizontal-scroll">
|
||||
{#each photoItems as item}
|
||||
{#if isAlbum(item)}
|
||||
<a href="/photos/{item.slug}" class="photo-link">
|
||||
<img src={item.coverPhoto.src} alt={item.title} />
|
||||
<p class="caption">{item.title}</p>
|
||||
</a>
|
||||
{:else}
|
||||
{@const mediaId = item.id.replace(/^(media|photo)-/, '')}
|
||||
<a href="/photos/{albumSlug ? `${albumSlug}/${mediaId}` : `p/${mediaId}`}" class="photo-link">
|
||||
<img src={item.src} alt={item.alt} />
|
||||
{#if item.caption}
|
||||
<p class="caption">{item.caption}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{#each photos as photo}
|
||||
{@const mediaId = photo.id.replace(/^(media|photo)-/, '')}
|
||||
<a href="/photos/{mediaId}" class="photo-link">
|
||||
<img src={photo.src} alt={photo.alt} />
|
||||
{#if showCaptions && photo.caption}
|
||||
<p class="caption">{photo.caption}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
|
@ -1,69 +1,13 @@
|
|||
<script lang="ts">
|
||||
import Masonry from 'svelte-bricks'
|
||||
import PhotoItem from './PhotoItem.svelte'
|
||||
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||
import PhotoGrid from './PhotoGrid.svelte'
|
||||
import type { Photo } from '$lib/types/photos'
|
||||
|
||||
const {
|
||||
photoItems
|
||||
}: {
|
||||
photoItems: PhotoItemType[]
|
||||
} = $props()
|
||||
interface Props {
|
||||
photos: Photo[]
|
||||
columns?: 1 | 2 | 3 | 'auto'
|
||||
}
|
||||
|
||||
// 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 = 16 // Gap between items (equivalent to $unit-2x)
|
||||
|
||||
// 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
|
||||
let { photos = [], columns = 'auto' }: Props = $props()
|
||||
</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} />
|
||||
{/snippet}
|
||||
</Masonry>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.masonry-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.photo-masonry) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<PhotoGrid {photos} {columns} masonry={true} gap="medium" />
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
<script lang="ts">
|
||||
import PhotoItem from './PhotoItem.svelte'
|
||||
import type { PhotoItem as PhotoItemType, Photo } from '$lib/types/photos'
|
||||
|
||||
interface Props {
|
||||
photoItems: PhotoItemType[]
|
||||
columns?: 1 | 2 | 3 | 'auto'
|
||||
gap?: 'small' | 'medium' | 'large'
|
||||
showCaptions?: boolean
|
||||
albumSlug?: string
|
||||
onItemClick?: (item: PhotoItemType) => void
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
photoItems = [],
|
||||
columns = 'auto',
|
||||
gap = 'medium',
|
||||
showCaptions = false,
|
||||
albumSlug,
|
||||
onItemClick,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
// Gap size mapping
|
||||
const gapSizes = {
|
||||
small: '$unit',
|
||||
medium: '$unit-2x',
|
||||
large: '$unit-4x'
|
||||
}
|
||||
|
||||
// Check if an image is ultrawide (aspect ratio > 2.5)
|
||||
function isUltrawide(item: PhotoItemType): boolean {
|
||||
if ('photos' in item) return false // Albums can't be ultrawide
|
||||
const photo = item as Photo
|
||||
return (photo.aspectRatio || photo.width / photo.height) > 2.5
|
||||
}
|
||||
|
||||
// Process items for three-column layout with ultrawide support
|
||||
function processItemsForThreeColumns(items: PhotoItemType[]): Array<{
|
||||
item: PhotoItemType
|
||||
span: number
|
||||
columnStart?: number
|
||||
}> {
|
||||
const processed = []
|
||||
let currentColumn = 0
|
||||
|
||||
for (const item of items) {
|
||||
if (isUltrawide(item)) {
|
||||
// Ultrawide images span based on current position
|
||||
if (currentColumn === 0) {
|
||||
// Left-aligned, spans 2 columns
|
||||
processed.push({ item, span: 2, columnStart: 1 })
|
||||
currentColumn = 2
|
||||
} else if (currentColumn === 1) {
|
||||
// Center, spans 2 columns
|
||||
processed.push({ item, span: 2, columnStart: 2 })
|
||||
currentColumn = 0 // Wrap to next row
|
||||
} else {
|
||||
// Right column, place in next row spanning 2 from left
|
||||
processed.push({ item, span: 2, columnStart: 1 })
|
||||
currentColumn = 2
|
||||
}
|
||||
} else {
|
||||
// Regular images
|
||||
processed.push({ item, span: 1 })
|
||||
currentColumn = (currentColumn + 1) % 3
|
||||
}
|
||||
}
|
||||
|
||||
return processed
|
||||
}
|
||||
|
||||
// Split items into columns for column-based layouts
|
||||
function splitIntoColumns(items: PhotoItemType[], numColumns: number): PhotoItemType[][] {
|
||||
const columns: PhotoItemType[][] = Array.from({ length: numColumns }, () => [])
|
||||
|
||||
items.forEach((item, index) => {
|
||||
columns[index % numColumns].push(item)
|
||||
})
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// Process items based on layout
|
||||
const processedItems = $derived(() => {
|
||||
if (columns === 3) {
|
||||
return processItemsForThreeColumns(photoItems)
|
||||
}
|
||||
return photoItems.map(item => ({ item, span: 1 }))
|
||||
})
|
||||
|
||||
const columnItems = $derived(() => {
|
||||
if (columns === 1 || columns === 2) {
|
||||
return splitIntoColumns(photoItems, columns)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// CSS classes based on props
|
||||
const gridClass = $derived(
|
||||
`photo-grid photo-grid--${columns === 'auto' ? 'auto' : `${columns}-column`} photo-grid--gap-${gap} ${className}`
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class={gridClass}>
|
||||
{#if columns === 1 || columns === 2}
|
||||
<!-- Column-based layout -->
|
||||
{#each columnItems as columnPhotos, colIndex}
|
||||
<div class="photo-grid__column">
|
||||
{#each columnPhotos as item}
|
||||
<div class="photo-grid__item">
|
||||
<PhotoItem {item} />
|
||||
{#if showCaptions && !('photos' in item)}
|
||||
<p class="photo-caption">{item.caption || ''}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else if columns === 3}
|
||||
<!-- Three column grid with ultrawide support -->
|
||||
<div class="photo-grid__three-column">
|
||||
{#each processedItems() as { item, span, columnStart }}
|
||||
<div
|
||||
class="photo-grid__item"
|
||||
class:ultrawide={span > 1}
|
||||
style={columnStart ? `grid-column-start: ${columnStart};` : ''}
|
||||
style:grid-column-end={span > 1 ? `span ${span}` : ''}
|
||||
>
|
||||
<PhotoItem {item} />
|
||||
{#if showCaptions && !('photos' in item)}
|
||||
<p class="photo-caption">{item.caption || ''}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Auto grid layout -->
|
||||
<div class="photo-grid__auto">
|
||||
{#each photoItems as item}
|
||||
<div class="photo-grid__item">
|
||||
<PhotoItem {item} />
|
||||
{#if showCaptions && !('photos' in item)}
|
||||
<p class="photo-caption">{item.caption || ''}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.photo-grid {
|
||||
width: 100%;
|
||||
|
||||
// Gap variations
|
||||
&--gap-small {
|
||||
--grid-gap: 8px;
|
||||
}
|
||||
&--gap-medium {
|
||||
--grid-gap: 16px;
|
||||
}
|
||||
&--gap-large {
|
||||
--grid-gap: 32px;
|
||||
}
|
||||
|
||||
// Column-based layouts
|
||||
&--1-column,
|
||||
&--2-column {
|
||||
display: flex;
|
||||
gap: var(--grid-gap);
|
||||
|
||||
@include breakpoint('mobile') {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
&__column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--grid-gap);
|
||||
}
|
||||
|
||||
// Three column grid
|
||||
&--3-column &__three-column {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--grid-gap);
|
||||
width: 100%;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
.ultrawide {
|
||||
grid-column: 1 / -1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint('mobile') {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
.ultrawide {
|
||||
grid-column: 1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto grid
|
||||
&--auto &__auto {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--grid-gap);
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
@include breakpoint('mobile') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 0; // Override PhotoItem default
|
||||
}
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
margin-top: $unit;
|
||||
font-size: 0.875rem;
|
||||
color: $gray-40;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@include breakpoint('mobile') {
|
||||
.photo-grid {
|
||||
&--2-column &__column {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,122 +1,236 @@
|
|||
<script lang="ts">
|
||||
import SmartImage from './SmartImage.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
import PhotoItem from './PhotoItem.svelte'
|
||||
import Masonry from 'svelte-bricks'
|
||||
import type { Photo } from '$lib/types/photos'
|
||||
|
||||
interface Props {
|
||||
photos: Media[]
|
||||
layout?: 'masonry' | 'grid'
|
||||
onPhotoClick?: (photo: Media) => void
|
||||
photos: Photo[]
|
||||
columns?: 1 | 2 | 3 | 'auto'
|
||||
gap?: 'small' | 'medium' | 'large'
|
||||
showCaptions?: boolean
|
||||
masonry?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { photos = [], layout = 'masonry', onPhotoClick, class: className = '' }: Props = $props()
|
||||
let {
|
||||
photos = [],
|
||||
columns = 'auto',
|
||||
gap = 'medium',
|
||||
showCaptions = false,
|
||||
masonry = false,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
function handlePhotoClick(photo: Media) {
|
||||
if (onPhotoClick) {
|
||||
onPhotoClick(photo)
|
||||
}
|
||||
|
||||
|
||||
// Split photos into columns for column-based layouts
|
||||
function splitIntoColumns(photos: Photo[], numColumns: number): Photo[][] {
|
||||
const columns: Photo[][] = Array.from({ length: numColumns }, () => [])
|
||||
|
||||
photos.forEach((photo, index) => {
|
||||
columns[index % numColumns].push(photo)
|
||||
})
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
|
||||
const columnPhotos = $derived(
|
||||
(columns === 1 || columns === 2 || columns === 3) && !masonry ? splitIntoColumns(photos, columns) : []
|
||||
)
|
||||
|
||||
// Window width for responsive masonry
|
||||
let windowWidth = $state(0)
|
||||
|
||||
// Calculate masonry column widths based on columns prop
|
||||
const masonryConfig = $derived(() => {
|
||||
if (!masonry) return null
|
||||
|
||||
const gapSize = gap === 'small' ? 8 : gap === 'large' ? 32 : 16
|
||||
|
||||
if (columns === 1) {
|
||||
const width = windowWidth - 64 // Account for padding
|
||||
return { minColWidth: width, maxColWidth: width, gap: gapSize }
|
||||
} else if (columns === 2) {
|
||||
const width = Math.floor((windowWidth - 64 - gapSize) / 2)
|
||||
return { minColWidth: width - 10, maxColWidth: width + 10, gap: gapSize }
|
||||
} else if (columns === 3) {
|
||||
const width = Math.floor((windowWidth - 64 - (gapSize * 2)) / 3)
|
||||
return { minColWidth: width - 10, maxColWidth: width + 10, gap: gapSize }
|
||||
} else {
|
||||
// Auto columns
|
||||
return { minColWidth: 200, maxColWidth: 400, gap: gapSize }
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure unique IDs for svelte-bricks
|
||||
const getId = (photo: Photo) => photo.id
|
||||
|
||||
// CSS classes based on props
|
||||
const gridClass = $derived(
|
||||
`photo-grid photo-grid--${columns === 'auto' ? 'auto' : `${columns}-column`} photo-grid--gap-${gap} ${masonry ? 'photo-grid--masonry' : 'photo-grid--square'} ${className}`
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="photo-grid photo-grid-{layout} {className}">
|
||||
{#each photos as photo}
|
||||
<div class="grid-item">
|
||||
{#if onPhotoClick}
|
||||
<button class="photo-button" onclick={() => handlePhotoClick(photo)} type="button">
|
||||
<SmartImage media={photo} alt={photo.description || ''} class="grid-photo" />
|
||||
</button>
|
||||
{:else}
|
||||
<SmartImage media={photo} alt={photo.description || ''} class="grid-photo" />
|
||||
{/if}
|
||||
<svelte:window bind:innerWidth={windowWidth} />
|
||||
|
||||
<div class={gridClass}>
|
||||
{#if masonry && masonryConfig()}
|
||||
{@const config = masonryConfig()}
|
||||
<!-- Masonry layout using svelte-bricks -->
|
||||
<Masonry
|
||||
items={photos}
|
||||
minColWidth={config.minColWidth}
|
||||
maxColWidth={config.maxColWidth}
|
||||
gap={config.gap}
|
||||
{getId}
|
||||
animate={false}
|
||||
duration={0}
|
||||
class="photo-masonry"
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<div class="photo-grid__item">
|
||||
<PhotoItem item={item} />
|
||||
{#if showCaptions}
|
||||
<p class="photo-caption">{item.caption || ''}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Masonry>
|
||||
{:else if (columns === 1 || columns === 2 || columns === 3) && !masonry}
|
||||
<!-- Column-based layout for square thumbnails -->
|
||||
{#each columnPhotos as column, colIndex}
|
||||
<div class="photo-grid__column">
|
||||
{#each column as photo}
|
||||
<div class="photo-grid__item">
|
||||
<PhotoItem item={photo} />
|
||||
{#if showCaptions}
|
||||
<p class="photo-caption">{photo.caption || ''}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Auto grid layout -->
|
||||
<div class="photo-grid__auto">
|
||||
{#each photos as photo}
|
||||
<div class="photo-grid__item">
|
||||
<PhotoItem item={photo} />
|
||||
{#if showCaptions}
|
||||
<p class="photo-caption">{photo.caption || ''}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
@import '$styles/mixins.scss';
|
||||
|
||||
.photo-grid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.photo-grid-masonry {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: $unit-2x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $unit;
|
||||
// Gap variations
|
||||
&--gap-small {
|
||||
--grid-gap: 8px;
|
||||
}
|
||||
&--gap-medium {
|
||||
--grid-gap: 16px;
|
||||
}
|
||||
&--gap-large {
|
||||
--grid-gap: 32px;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
// Column-based layouts
|
||||
&--1-column,
|
||||
&--2-column,
|
||||
&--3-column {
|
||||
display: flex;
|
||||
gap: var(--grid-gap);
|
||||
|
||||
@include breakpoint('mobile') {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
&__column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--grid-gap);
|
||||
}
|
||||
|
||||
// Square thumbnail mode (non-masonry, except for single column)
|
||||
&--square {
|
||||
// Only apply square thumbnails for multi-column and auto layouts
|
||||
&.photo-grid--2-column,
|
||||
&.photo-grid--3-column,
|
||||
&.photo-grid--auto {
|
||||
.photo-grid__item {
|
||||
:global(.photo-item) {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.photo-button) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:global(.single-photo) {
|
||||
height: 100%;
|
||||
aspect-ratio: 1 !important;
|
||||
}
|
||||
|
||||
:global(.single-photo img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Masonry mode using svelte-bricks
|
||||
&--masonry {
|
||||
:global(.photo-masonry) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto grid
|
||||
&--auto &__auto {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--grid-gap);
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
@include breakpoint('mobile') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 0; // Override PhotoItem default
|
||||
}
|
||||
}
|
||||
|
||||
.photo-grid-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $unit-2x;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.grid-photo) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.photo-caption {
|
||||
margin-top: $unit;
|
||||
font-size: 0.875rem;
|
||||
color: $gray-40;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: $image-corner-radius;
|
||||
}
|
||||
|
||||
.photo-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
transition: background 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:active::after {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
// Responsive adjustments
|
||||
@include breakpoint('mobile') {
|
||||
.photo-grid {
|
||||
&--2-column &__column {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.grid-photo) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<script lang="ts">
|
||||
import PhotoGrid from './PhotoGrid.new.svelte'
|
||||
import type { PhotoItem } from '$lib/types/photos'
|
||||
|
||||
interface Props {
|
||||
photoItems: PhotoItem[]
|
||||
albumSlug?: string
|
||||
}
|
||||
|
||||
let { photoItems = [], albumSlug }: Props = $props()
|
||||
</script>
|
||||
|
||||
<PhotoGrid {photoItems} columns={1} gap="large" showCaptions={true} {albumSlug} />
|
||||
|
|
@ -1,60 +1,12 @@
|
|||
<script lang="ts">
|
||||
import PhotoItem from './PhotoItem.svelte'
|
||||
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||
import { isAlbum } from '$lib/types/photos'
|
||||
import PhotoGrid from './PhotoGrid.svelte'
|
||||
import type { Photo } from '$lib/types/photos'
|
||||
|
||||
const {
|
||||
photoItems,
|
||||
albumSlug
|
||||
}: {
|
||||
photoItems: PhotoItemType[]
|
||||
albumSlug?: string
|
||||
} = $props()
|
||||
interface Props {
|
||||
photos: Photo[]
|
||||
}
|
||||
|
||||
let { photos = [] }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="single-column-grid">
|
||||
{#each photoItems as item}
|
||||
<div class="photo-container">
|
||||
<PhotoItem {item} {albumSlug} />
|
||||
{#if !isAlbum(item) && item.caption}
|
||||
<div class="photo-details">
|
||||
<p class="photo-caption">{item.caption}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.single-column-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.photo-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.photo-details {
|
||||
padding: $unit-2x 0 0;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: $gray-20;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
.single-column-grid {
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.photo-details {
|
||||
padding: $unit 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<PhotoGrid {photos} columns={1} gap="large" showCaptions={true} />
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<script lang="ts">
|
||||
import PhotoGrid from './PhotoGrid.new.svelte'
|
||||
import type { PhotoItem } from '$lib/types/photos'
|
||||
|
||||
interface Props {
|
||||
photoItems: PhotoItem[]
|
||||
albumSlug?: string
|
||||
}
|
||||
|
||||
let { photoItems = [], albumSlug }: Props = $props()
|
||||
</script>
|
||||
|
||||
<PhotoGrid {photoItems} columns={3} gap="medium" {albumSlug} />
|
||||
|
|
@ -1,282 +1,12 @@
|
|||
<script lang="ts">
|
||||
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||
import { isAlbum } from '$lib/types/photos'
|
||||
import { goto } from '$app/navigation'
|
||||
import PhotoGrid from './PhotoGrid.svelte'
|
||||
import type { Photo } from '$lib/types/photos'
|
||||
|
||||
const {
|
||||
photoItems
|
||||
}: {
|
||||
photoItems: PhotoItemType[]
|
||||
} = $props()
|
||||
|
||||
// Function to determine if an image is ultrawide (aspect ratio > 2:1)
|
||||
function isUltrawide(item: PhotoItemType): boolean {
|
||||
if (isAlbum(item)) {
|
||||
const { width, height } = item.coverPhoto
|
||||
return width / height > 2
|
||||
} else {
|
||||
return item.width / item.height > 2
|
||||
}
|
||||
interface Props {
|
||||
photos: Photo[]
|
||||
}
|
||||
|
||||
// Process items to determine grid placement
|
||||
let gridItems = $state<Array<{ item: PhotoItemType; spanFull: boolean }>>([])
|
||||
|
||||
$effect(() => {
|
||||
// First, separate ultrawide and regular items
|
||||
const ultrawideItems: PhotoItemType[] = []
|
||||
const regularItems: PhotoItemType[] = []
|
||||
|
||||
photoItems.forEach((item) => {
|
||||
if (isUltrawide(item)) {
|
||||
ultrawideItems.push(item)
|
||||
} else {
|
||||
regularItems.push(item)
|
||||
}
|
||||
})
|
||||
|
||||
// Build the grid ensuring we fill rows of 3
|
||||
const processedItems: Array<{ item: PhotoItemType; spanFull: boolean }> = []
|
||||
let regularIndex = 0
|
||||
let ultrawideIndex = 0
|
||||
let rowsSinceLastUltrawide = 1 // Start with 1 to allow ultrawide at beginning
|
||||
|
||||
while (regularIndex < regularItems.length || ultrawideIndex < ultrawideItems.length) {
|
||||
const remainingRegular = regularItems.length - regularIndex
|
||||
const remainingUltrawide = ultrawideItems.length - ultrawideIndex
|
||||
|
||||
// Check if we can/should place an ultrawide
|
||||
if (
|
||||
ultrawideIndex < ultrawideItems.length &&
|
||||
rowsSinceLastUltrawide >= 1 &&
|
||||
(remainingRegular === 0 || remainingRegular >= 3)
|
||||
) {
|
||||
// Place ultrawide
|
||||
processedItems.push({
|
||||
item: ultrawideItems[ultrawideIndex],
|
||||
spanFull: true
|
||||
})
|
||||
ultrawideIndex++
|
||||
rowsSinceLastUltrawide = 0
|
||||
} else if (regularIndex < regularItems.length && remainingRegular >= 3) {
|
||||
// Place a full row of 3 regular photos
|
||||
for (let i = 0; i < 3 && regularIndex < regularItems.length; i++) {
|
||||
processedItems.push({
|
||||
item: regularItems[regularIndex],
|
||||
spanFull: false
|
||||
})
|
||||
regularIndex++
|
||||
}
|
||||
rowsSinceLastUltrawide++
|
||||
} else if (regularIndex < regularItems.length) {
|
||||
// Place remaining regular photos (less than 3)
|
||||
while (regularIndex < regularItems.length) {
|
||||
processedItems.push({
|
||||
item: regularItems[regularIndex],
|
||||
spanFull: false
|
||||
})
|
||||
regularIndex++
|
||||
}
|
||||
rowsSinceLastUltrawide++
|
||||
} else {
|
||||
// Only ultrawides left, place them with spacing
|
||||
if (ultrawideIndex < ultrawideItems.length) {
|
||||
processedItems.push({
|
||||
item: ultrawideItems[ultrawideIndex],
|
||||
spanFull: true
|
||||
})
|
||||
ultrawideIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gridItems = processedItems
|
||||
})
|
||||
|
||||
function handleClick(item: PhotoItemType) {
|
||||
if (isAlbum(item)) {
|
||||
// Navigate to album page using the slug
|
||||
goto(`/albums/${item.slug}`)
|
||||
} else {
|
||||
// For individual photos, check if we have album context
|
||||
// Always navigate to individual photo page using the media ID
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
|
||||
goto(`/photos/${mediaId}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getImageSrc(item: PhotoItemType): string {
|
||||
return isAlbum(item) ? item.coverPhoto.src : item.src
|
||||
}
|
||||
|
||||
function getImageAlt(item: PhotoItemType): string {
|
||||
return isAlbum(item) ? item.coverPhoto.alt : item.alt
|
||||
}
|
||||
let { photos = [] }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="three-column-grid">
|
||||
{#each gridItems as { item, spanFull }}
|
||||
<button
|
||||
class="grid-item"
|
||||
class:span-full={spanFull}
|
||||
class:is-album={isAlbum(item)}
|
||||
onclick={() => handleClick(item)}
|
||||
type="button"
|
||||
>
|
||||
<div class="image-container">
|
||||
<img src={getImageSrc(item)} alt={getImageAlt(item)} loading="lazy" draggable="false" />
|
||||
</div>
|
||||
{#if isAlbum(item)}
|
||||
<div class="album-overlay">
|
||||
<div class="album-info">
|
||||
<span class="album-title">{item.title}</span>
|
||||
<span class="album-count">{item.photos.length} photos</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.three-column-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: $unit-2x;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
grid-column: span 1;
|
||||
position: relative;
|
||||
aspect-ratio: 1; // Square by default
|
||||
overflow: hidden;
|
||||
border-radius: $corner-radius;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&.span-full {
|
||||
grid-column: span 3;
|
||||
aspect-ratio: 3; // Wider aspect ratio for ultrawide images
|
||||
}
|
||||
}
|
||||
|
||||
.image-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.album-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
color: white;
|
||||
padding: $unit-2x;
|
||||
z-index: $z-index-base;
|
||||
}
|
||||
|
||||
.album-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.album-count {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
// Stack effect for albums
|
||||
.is-album {
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: $corner-radius;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: -3px;
|
||||
left: 3px;
|
||||
right: -3px;
|
||||
bottom: 3px;
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: -6px;
|
||||
left: 6px;
|
||||
right: -6px;
|
||||
bottom: 6px;
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
transform: rotate(-1.5deg) translateY(-0.5px);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: rotate(3deg) translateY(-1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
.three-column-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.grid-item.span-full {
|
||||
grid-column: span 2;
|
||||
aspect-ratio: 2; // Adjust aspect ratio for 2-column layout
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
.three-column-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
aspect-ratio: 4/3; // Slightly wider on mobile
|
||||
}
|
||||
|
||||
.grid-item.span-full {
|
||||
grid-column: span 1;
|
||||
aspect-ratio: 16/9; // Standard widescreen on mobile
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<PhotoGrid {photos} columns={3} gap="medium" />
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<script lang="ts">
|
||||
import PhotoGrid from './PhotoGrid.new.svelte'
|
||||
import type { PhotoItem } from '$lib/types/photos'
|
||||
|
||||
interface Props {
|
||||
photoItems: PhotoItem[]
|
||||
albumSlug?: string
|
||||
}
|
||||
|
||||
let { photoItems = [], albumSlug }: Props = $props()
|
||||
</script>
|
||||
|
||||
<PhotoGrid {photoItems} columns={2} gap="medium" {albumSlug} />
|
||||
|
|
@ -1,51 +1,12 @@
|
|||
<script lang="ts">
|
||||
import PhotoItem from '$components/PhotoItem.svelte'
|
||||
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||
import PhotoGrid from './PhotoGrid.svelte'
|
||||
import type { Photo } from '$lib/types/photos'
|
||||
|
||||
const {
|
||||
photoItems,
|
||||
albumSlug
|
||||
}: {
|
||||
photoItems: PhotoItemType[]
|
||||
albumSlug?: string
|
||||
} = $props()
|
||||
interface Props {
|
||||
photos: Photo[]
|
||||
}
|
||||
|
||||
// Split items into two columns
|
||||
const column1 = $derived(photoItems.filter((_, index) => index % 2 === 0))
|
||||
const column2 = $derived(photoItems.filter((_, index) => index % 2 === 1))
|
||||
let { photos = [] }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="two-column-grid">
|
||||
<div class="column">
|
||||
{#each column1 as item}
|
||||
<PhotoItem {item} {albumSlug} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="column">
|
||||
{#each column2 as item}
|
||||
<PhotoItem {item} {albumSlug} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.two-column-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<PhotoGrid {photos} columns={2} gap="medium" />
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import Page from '$components/Page.svelte'
|
||||
import MasonryPhotoGrid from '$components/MasonryPhotoGrid.svelte'
|
||||
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||
import BackButton from '$components/BackButton.svelte'
|
||||
import { generateMetaTags, generateImageGalleryJsonLd } from '$lib/utils/metadata'
|
||||
import { renderEdraContent, getContentExcerpt } from '$lib/utils/content'
|
||||
|
|
@ -189,7 +189,7 @@
|
|||
<!-- Legacy Photo Grid (for albums without composed content) -->
|
||||
{#if photoItems.length > 0}
|
||||
<div class="legacy-photos">
|
||||
<MasonryPhotoGrid {photoItems} />
|
||||
<PhotoGrid photos={photoItems} columns="auto" masonry={true} gap="medium" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-album">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
<script lang="ts">
|
||||
import MasonryPhotoGrid from '$components/MasonryPhotoGrid.svelte'
|
||||
import SingleColumnPhotoGrid from '$components/SingleColumnPhotoGrid.new.svelte'
|
||||
import TwoColumnPhotoGrid from '$components/TwoColumnPhotoGrid.new.svelte'
|
||||
import HorizontalScrollPhotoGrid from '$components/HorizontalScrollPhotoGrid.svelte'
|
||||
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||
import HorizontalPhotoScroll from '$components/HorizontalPhotoScroll.svelte'
|
||||
import LoadingSpinner from '$components/admin/LoadingSpinner.svelte'
|
||||
import ViewModeSelector from '$components/ViewModeSelector.svelte'
|
||||
import type { ViewMode } from '$components/ViewModeSelector.svelte'
|
||||
|
|
@ -12,7 +10,7 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import { browser } from '$app/environment'
|
||||
import type { PageData } from './$types'
|
||||
import type { PhotoItem } from '$lib/types/photos'
|
||||
import type { Photo } from '$lib/types/photos'
|
||||
import type { Snapshot } from './$types'
|
||||
|
||||
const { data }: { data: PageData } = $props()
|
||||
|
|
@ -21,7 +19,7 @@
|
|||
const loaderState = new LoaderState()
|
||||
|
||||
// Initialize state with server-side data
|
||||
let allPhotoItems = $state<PhotoItem[]>(data.photoItems || [])
|
||||
let allPhotos = $state<Photo[]>(data.photos || [])
|
||||
let currentOffset = $state(data.pagination?.limit || 20)
|
||||
let containerWidth = $state<'normal' | 'wide'>('normal')
|
||||
|
||||
|
|
@ -34,7 +32,7 @@
|
|||
)
|
||||
|
||||
// Track loaded photo IDs to prevent duplicates
|
||||
let loadedPhotoIds = $state(new Set(data.photoItems?.map((item) => item.id) || []))
|
||||
let loadedPhotoIds = $state(new Set(data.photos?.map((photo) => photo.id) || []))
|
||||
|
||||
const error = $derived(data.error)
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
|
@ -73,12 +71,12 @@
|
|||
if (!response.ok) break
|
||||
|
||||
const result = await response.json()
|
||||
const newItems = (result.photoItems || []).filter(
|
||||
(item: PhotoItem) => !loadedPhotoIds.has(item.id)
|
||||
const newPhotos = (result.photoItems || []).filter(
|
||||
(photo: Photo) => !loadedPhotoIds.has(photo.id)
|
||||
)
|
||||
|
||||
newItems.forEach((item: PhotoItem) => loadedPhotoIds.add(item.id))
|
||||
allPhotoItems = [...allPhotoItems, ...newItems]
|
||||
newPhotos.forEach((photo: Photo) => loadedPhotoIds.add(photo.id))
|
||||
allPhotos = [...allPhotos, ...newPhotos]
|
||||
currentOffset += result.pagination?.limit || 50
|
||||
|
||||
if (!result.pagination?.hasMore) break
|
||||
|
|
@ -100,21 +98,21 @@
|
|||
const data = await response.json()
|
||||
|
||||
// Filter out duplicates
|
||||
const newItems = (data.photoItems || []).filter(
|
||||
(item: PhotoItem) => !loadedPhotoIds.has(item.id)
|
||||
const newPhotos = (data.photoItems || []).filter(
|
||||
(photo: Photo) => !loadedPhotoIds.has(photo.id)
|
||||
)
|
||||
|
||||
// Add new photo IDs to the set
|
||||
newItems.forEach((item: PhotoItem) => loadedPhotoIds.add(item.id))
|
||||
newPhotos.forEach((photo: Photo) => loadedPhotoIds.add(photo.id))
|
||||
|
||||
// Append new photos to existing list
|
||||
allPhotoItems = [...allPhotoItems, ...newItems]
|
||||
allPhotos = [...allPhotos, ...newPhotos]
|
||||
|
||||
// Update pagination state
|
||||
currentOffset += data.pagination?.limit || 20
|
||||
|
||||
// Update loader state
|
||||
if (!data.pagination?.hasMore || newItems.length === 0) {
|
||||
if (!data.pagination?.hasMore || newPhotos.length === 0) {
|
||||
loaderState.complete()
|
||||
} else {
|
||||
loaderState.loaded()
|
||||
|
|
@ -214,7 +212,7 @@
|
|||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if allPhotoItems.length === 0}
|
||||
{:else if allPhotos.length === 0}
|
||||
<div class="empty-container">
|
||||
<div class="empty-message">
|
||||
<h2>No photos yet</h2>
|
||||
|
|
@ -231,13 +229,13 @@
|
|||
|
||||
<div class="grid-container" class:full-width={viewMode === 'horizontal'}>
|
||||
{#if viewMode === 'masonry'}
|
||||
<MasonryPhotoGrid photoItems={allPhotoItems} />
|
||||
<PhotoGrid photos={allPhotos} columns="auto" masonry={true} gap="medium" />
|
||||
{:else if viewMode === 'single'}
|
||||
<SingleColumnPhotoGrid photoItems={allPhotoItems} />
|
||||
<PhotoGrid photos={allPhotos} columns={1} gap="large" showCaptions={true} />
|
||||
{:else if viewMode === 'two-column'}
|
||||
<TwoColumnPhotoGrid photoItems={allPhotoItems} />
|
||||
<PhotoGrid photos={allPhotos} columns={2} gap="medium" />
|
||||
{:else if viewMode === 'horizontal'}
|
||||
<HorizontalScrollPhotoGrid photoItems={allPhotoItems} />
|
||||
<HorizontalPhotoScroll photos={allPhotos} />
|
||||
{#if isLoadingAll}
|
||||
<div class="loading-more-indicator">
|
||||
<LoadingSpinner size="small" text="Loading all photos..." />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const load: PageLoad = async ({ fetch }) => {
|
|||
|
||||
const data = await response.json()
|
||||
return {
|
||||
photoItems: data.photoItems || [],
|
||||
photos: data.photoItems || [], // API still returns photoItems but contains only photos
|
||||
pagination: data.pagination || null
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -17,7 +17,7 @@ export const load: PageLoad = async ({ fetch }) => {
|
|||
|
||||
// Fallback to empty array if API fails
|
||||
return {
|
||||
photoItems: [],
|
||||
photos: [],
|
||||
pagination: null,
|
||||
error: 'Failed to load photos'
|
||||
}
|
||||
|
|
|
|||
233
src/routes/test-grids/+page.svelte
Normal file
233
src/routes/test-grids/+page.svelte
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<script lang="ts">
|
||||
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||
import type { Photo } from '$lib/types/photos'
|
||||
|
||||
// Sample data for testing
|
||||
const samplePhotos: Photo[] = [
|
||||
{
|
||||
id: 'photo-1',
|
||||
src: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4',
|
||||
alt: 'Mountain landscape',
|
||||
caption: 'Beautiful mountain view',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
aspectRatio: 16/9
|
||||
},
|
||||
{
|
||||
id: 'photo-2',
|
||||
src: 'https://images.unsplash.com/photo-1472214103451-9374bd1c798e',
|
||||
alt: 'Valley view',
|
||||
caption: 'Green valley',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
aspectRatio: 3/2
|
||||
},
|
||||
{
|
||||
id: 'photo-3',
|
||||
src: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e',
|
||||
alt: 'Forest path',
|
||||
caption: 'Winding forest trail',
|
||||
width: 2400,
|
||||
height: 800,
|
||||
aspectRatio: 3/1 // Ultrawide
|
||||
},
|
||||
{
|
||||
id: 'photo-4',
|
||||
src: 'https://images.unsplash.com/photo-1433086966358-54859d0ed716',
|
||||
alt: 'Waterfall',
|
||||
caption: 'Cascading waterfall',
|
||||
width: 800,
|
||||
height: 1200,
|
||||
aspectRatio: 2/3
|
||||
},
|
||||
{
|
||||
id: 'photo-5',
|
||||
src: 'https://images.unsplash.com/photo-1500534314209-a25ddb2bd429',
|
||||
alt: 'Sunrise',
|
||||
caption: 'Golden hour sunrise',
|
||||
width: 1200,
|
||||
height: 1200,
|
||||
aspectRatio: 1
|
||||
},
|
||||
{
|
||||
id: 'photo-6',
|
||||
src: 'https://images.unsplash.com/photo-1439853949127-fa647821eba0',
|
||||
alt: 'Desert dunes',
|
||||
caption: 'Sandy desert landscape',
|
||||
width: 1000,
|
||||
height: 1500,
|
||||
aspectRatio: 2/3
|
||||
},
|
||||
{
|
||||
id: 'photo-7',
|
||||
src: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4',
|
||||
alt: 'Mountain peaks',
|
||||
caption: 'Snow-capped mountains',
|
||||
width: 1800,
|
||||
height: 1200,
|
||||
aspectRatio: 3/2
|
||||
},
|
||||
{
|
||||
id: 'photo-8',
|
||||
src: 'https://images.unsplash.com/photo-1519904981063-b0cf448d479e',
|
||||
alt: 'Ocean waves',
|
||||
caption: 'Crashing waves',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
aspectRatio: 16/9
|
||||
}
|
||||
]
|
||||
|
||||
// PhotoGrid parameters
|
||||
let columns: 1 | 2 | 3 | 'auto' = $state('auto')
|
||||
let gap: 'small' | 'medium' | 'large' = $state('medium')
|
||||
let masonry = $state(false)
|
||||
let showCaptions = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="test-container">
|
||||
<h1>PhotoGrid Component Test</h1>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>
|
||||
Columns:
|
||||
<select bind:value={columns}>
|
||||
<option value={1}>1 Column</option>
|
||||
<option value={2}>2 Columns</option>
|
||||
<option value={3}>3 Columns</option>
|
||||
<option value="auto">Auto</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>
|
||||
Gap:
|
||||
<select bind:value={gap}>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={masonry} />
|
||||
Masonry Layout
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={showCaptions} />
|
||||
Show Captions
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-display">
|
||||
<code>{`<PhotoGrid photos={photos} columns={${columns}} gap="${gap}" masonry={${masonry}} showCaptions={${showCaptions}} />`}</code>
|
||||
</div>
|
||||
|
||||
<div class="grid-preview">
|
||||
<PhotoGrid
|
||||
photos={samplePhotos}
|
||||
{columns}
|
||||
{gap}
|
||||
{masonry}
|
||||
{showCaptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.test-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 2rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-display {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.config-display code {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.grid-preview {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.test-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-preview {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue