Rudimentary photo view modes
This commit is contained in:
parent
d73619aa24
commit
344d2f79e4
8 changed files with 495 additions and 4 deletions
12
src/assets/icons/view-grid.svg
Normal file
12
src/assets/icons/view-grid.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- 3-column grid icon -->
|
||||||
|
<rect x="3" y="3" width="5" height="5" rx="1"/>
|
||||||
|
<rect x="9.5" y="3" width="5" height="5" rx="1"/>
|
||||||
|
<rect x="16" y="3" width="5" height="5" rx="1"/>
|
||||||
|
<rect x="3" y="9.5" width="5" height="5" rx="1"/>
|
||||||
|
<rect x="9.5" y="9.5" width="5" height="5" rx="1"/>
|
||||||
|
<rect x="16" y="9.5" width="5" height="5" rx="1"/>
|
||||||
|
<rect x="3" y="16" width="5" height="5" rx="1"/>
|
||||||
|
<rect x="9.5" y="16" width="5" height="5" rx="1"/>
|
||||||
|
<rect x="16" y="16" width="5" height="5" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 686 B |
7
src/assets/icons/view-masonry.svg
Normal file
7
src/assets/icons/view-masonry.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Masonry grid icon with varying heights -->
|
||||||
|
<rect x="3" y="3" width="7" height="8" rx="1"/>
|
||||||
|
<rect x="3" y="13" width="7" height="6" rx="1"/>
|
||||||
|
<rect x="12" y="3" width="7" height="5" rx="1"/>
|
||||||
|
<rect x="12" y="10" width="7" height="9" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 442 B |
4
src/assets/icons/view-single.svg
Normal file
4
src/assets/icons/view-single.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Single column view icon - rounded square -->
|
||||||
|
<rect x="5" y="5" width="14" height="14" rx="3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 292 B |
60
src/lib/components/SingleColumnPhotoGrid.svelte
Normal file
60
src/lib/components/SingleColumnPhotoGrid.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import PhotoItem from './PhotoItem.svelte'
|
||||||
|
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||||
|
import { isAlbum } from '$lib/types/photos'
|
||||||
|
|
||||||
|
const {
|
||||||
|
photoItems,
|
||||||
|
albumSlug
|
||||||
|
}: {
|
||||||
|
photoItems: PhotoItemType[]
|
||||||
|
albumSlug?: string
|
||||||
|
} = $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: $grey-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
.single-column-grid {
|
||||||
|
gap: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-details {
|
||||||
|
padding: $unit 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
297
src/lib/components/ThreeColumnPhotoGrid.svelte
Normal file
297
src/lib/components/ThreeColumnPhotoGrid.svelte
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||||
|
import { isAlbum } from '$lib/types/photos'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
const {
|
||||||
|
photoItems,
|
||||||
|
albumSlug
|
||||||
|
}: {
|
||||||
|
photoItems: PhotoItemType[]
|
||||||
|
albumSlug?: string
|
||||||
|
} = $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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(`/photos/${item.slug}`)
|
||||||
|
} else {
|
||||||
|
// For individual photos, check if we have album context
|
||||||
|
if (albumSlug) {
|
||||||
|
// Navigate to photo within album
|
||||||
|
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
|
||||||
|
goto(`/photos/${albumSlug}/${mediaId}`)
|
||||||
|
} else {
|
||||||
|
// Navigate to individual photo page using the media ID
|
||||||
|
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
|
||||||
|
goto(`/photos/p/${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
|
||||||
|
}
|
||||||
|
</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 {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.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: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|
||||||
78
src/lib/components/ViewModeSelector.svelte
Normal file
78
src/lib/components/ViewModeSelector.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ViewMasonryIcon from '$icons/view-masonry.svg?component'
|
||||||
|
import ViewSingleIcon from '$icons/view-single.svg?component'
|
||||||
|
import ViewGridIcon from '$icons/view-grid.svg?component'
|
||||||
|
|
||||||
|
export type ViewMode = 'masonry' | 'single' | 'grid'
|
||||||
|
|
||||||
|
let {
|
||||||
|
currentMode = $bindable('masonry'),
|
||||||
|
onModeChange
|
||||||
|
}: {
|
||||||
|
currentMode?: ViewMode
|
||||||
|
onModeChange?: (mode: ViewMode) => void
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
function handleModeChange(mode: ViewMode) {
|
||||||
|
currentMode = mode
|
||||||
|
onModeChange?.(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewModes = [
|
||||||
|
{ mode: 'masonry' as ViewMode, icon: ViewMasonryIcon, label: 'Masonry view' },
|
||||||
|
{ mode: 'single' as ViewMode, icon: ViewSingleIcon, label: 'Single column view' },
|
||||||
|
{ mode: 'grid' as ViewMode, icon: ViewGridIcon, label: 'Grid view' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="view-mode-selector" role="group" aria-label="View mode selection">
|
||||||
|
{#each viewModes as { mode, icon: Icon, label }}
|
||||||
|
<button
|
||||||
|
class="view-mode-button"
|
||||||
|
class:active={currentMode === mode}
|
||||||
|
onclick={() => handleModeChange(mode)}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={currentMode === mode}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.view-mode-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-2x;
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: $grey-60;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $grey-30;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: $red-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
@ -112,7 +112,7 @@ export const GET: RequestHandler = async (event) => {
|
||||||
description: album.description || undefined,
|
description: album.description || undefined,
|
||||||
coverPhoto: {
|
coverPhoto: {
|
||||||
id: `cover-${firstMedia.id}`,
|
id: `cover-${firstMedia.id}`,
|
||||||
src: firstMedia.thumbnailUrl || firstMedia.url,
|
src: firstMedia.url,
|
||||||
alt: firstMedia.photoCaption || album.title,
|
alt: firstMedia.photoCaption || album.title,
|
||||||
caption: firstMedia.photoCaption || undefined,
|
caption: firstMedia.photoCaption || undefined,
|
||||||
width: firstMedia.width || 400,
|
width: firstMedia.width || 400,
|
||||||
|
|
@ -120,7 +120,7 @@ export const GET: RequestHandler = async (event) => {
|
||||||
},
|
},
|
||||||
photos: album.media.map((albumMedia) => ({
|
photos: album.media.map((albumMedia) => ({
|
||||||
id: `media-${albumMedia.media.id}`,
|
id: `media-${albumMedia.media.id}`,
|
||||||
src: albumMedia.media.thumbnailUrl || albumMedia.media.url,
|
src: albumMedia.media.url,
|
||||||
alt: albumMedia.media.photoCaption || albumMedia.media.filename,
|
alt: albumMedia.media.photoCaption || albumMedia.media.filename,
|
||||||
caption: albumMedia.media.photoCaption || undefined,
|
caption: albumMedia.media.photoCaption || undefined,
|
||||||
width: albumMedia.media.width || 400,
|
width: albumMedia.media.width || 400,
|
||||||
|
|
@ -137,7 +137,7 @@ export const GET: RequestHandler = async (event) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `media-${media.id}`,
|
id: `media-${media.id}`,
|
||||||
src: media.thumbnailUrl || media.url,
|
src: media.url,
|
||||||
alt: media.photoTitle || media.photoCaption || media.filename,
|
alt: media.photoTitle || media.photoCaption || media.filename,
|
||||||
caption: media.photoCaption || undefined,
|
caption: media.photoCaption || undefined,
|
||||||
width: media.width || 400,
|
width: media.width || 400,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
<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 ThreeColumnPhotoGrid from '$components/ThreeColumnPhotoGrid.svelte'
|
||||||
|
import ViewModeSelector from '$components/ViewModeSelector.svelte'
|
||||||
|
import type { ViewMode } from '$components/ViewModeSelector.svelte'
|
||||||
import LoadingSpinner from '$components/admin/LoadingSpinner.svelte'
|
import LoadingSpinner from '$components/admin/LoadingSpinner.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 { 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'
|
||||||
|
|
||||||
|
|
@ -15,6 +20,26 @@
|
||||||
// Initialize state with server-side data
|
// Initialize state with server-side data
|
||||||
let allPhotoItems = $state<PhotoItem[]>(data.photoItems || [])
|
let allPhotoItems = $state<PhotoItem[]>(data.photoItems || [])
|
||||||
let currentOffset = $state(data.pagination?.limit || 20)
|
let currentOffset = $state(data.pagination?.limit || 20)
|
||||||
|
|
||||||
|
// View mode state with localStorage persistence
|
||||||
|
let viewMode = $state<ViewMode>('masonry')
|
||||||
|
|
||||||
|
// Load saved view mode on mount
|
||||||
|
$effect(() => {
|
||||||
|
if (browser) {
|
||||||
|
const savedMode = localStorage.getItem('photoViewMode') as ViewMode
|
||||||
|
if (savedMode && ['masonry', 'single', 'grid'].includes(savedMode)) {
|
||||||
|
viewMode = savedMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save view mode when it changes
|
||||||
|
function handleViewModeChange(mode: ViewMode) {
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('photoViewMode', mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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) || []))
|
||||||
|
|
@ -116,7 +141,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<MasonryPhotoGrid photoItems={allPhotoItems} />
|
<ViewModeSelector bind:currentMode={viewMode} onModeChange={handleViewModeChange} />
|
||||||
|
|
||||||
|
{#if viewMode === 'masonry'}
|
||||||
|
<MasonryPhotoGrid photoItems={allPhotoItems} />
|
||||||
|
{:else if viewMode === 'single'}
|
||||||
|
<SingleColumnPhotoGrid photoItems={allPhotoItems} />
|
||||||
|
{:else if viewMode === 'grid'}
|
||||||
|
<ThreeColumnPhotoGrid photoItems={allPhotoItems} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<InfiniteLoader
|
<InfiniteLoader
|
||||||
{loaderState}
|
{loaderState}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue