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:
Justin Edmund 2025-06-26 08:13:30 -04:00
parent e92cc2393e
commit 5370ae020d
14 changed files with 512 additions and 871 deletions

View file

@ -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>
{#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>
{/if}
{/each}
</div>

View file

@ -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()
// 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
interface Props {
photos: Photo[]
columns?: 1 | 2 | 3 | 'auto'
}
})
// 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" />

View file

@ -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>

View file

@ -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" />
<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>
{/if}
</div>
<style lang="scss">
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.photo-grid {
width: 100%;
// Gap variations
&--gap-small {
--grid-gap: 8px;
}
&--gap-medium {
--grid-gap: 16px;
}
&--gap-large {
--grid-gap: 32px;
}
.photo-grid-masonry {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: $unit-2x;
// Column-based layouts
&--1-column,
&--2-column,
&--3-column {
display: flex;
gap: var(--grid-gap);
@include breakpoint('phone') {
grid-template-columns: 1fr;
gap: $unit;
}
.grid-item {
break-inside: avoid;
@include breakpoint('mobile') {
flex-direction: column;
}
}
.photo-grid-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $unit-2x;
@include breakpoint('tablet') {
grid-template-columns: repeat(2, 1fr);
&__column {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--grid-gap);
}
@include breakpoint('phone') {
grid-template-columns: 1fr;
gap: $unit;
}
.grid-item {
// 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(.grid-photo) {
: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;
}
}
.grid-item {
position: relative;
overflow: hidden;
border-radius: $image-corner-radius;
}
}
.photo-button {
display: block;
// Masonry mode using svelte-bricks
&--masonry {
:global(.photo-masonry) {
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);
}
}
:global(.grid-photo) {
// 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%;
height: auto;
display: block;
}
}
}
</style>

View file

@ -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} />

View file

@ -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} />

View file

@ -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} />

View file

@ -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" />

View file

@ -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} />

View file

@ -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" />

View file

@ -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">

View file

@ -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..." />

View file

@ -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'
}

View 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>