Fine-tuning slideshow

This commit is contained in:
Justin Edmund 2025-06-02 10:23:35 -07:00
parent 7894750d2b
commit 0354b798d3
6 changed files with 419 additions and 252 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts">
import LinkCard from './LinkCard.svelte'
import Slideshow from './Slideshow.svelte'
let { post }: { post: any } = $props()
@ -127,19 +128,40 @@
</div>
{/if}
{#if post.attachments && Array.isArray(post.attachments) && post.attachments.length > 0}
<div class="post-attachments">
<h3>Attachments</h3>
<div class="attachments-grid">
{#each post.attachments as attachment}
<div class="attachment-item">
<img src={attachment.url} alt={attachment.caption || 'Attachment'} loading="lazy" />
{#if attachment.caption}
<p class="attachment-caption">{attachment.caption}</p>
{/if}
</div>
{/each}
{#if post.album && post.album.photos && post.album.photos.length > 0}
<!-- Album slideshow -->
<div class="post-album">
<div class="album-header">
<h3>{post.album.title}</h3>
{#if post.album.description}
<p class="album-description">{post.album.description}</p>
{/if}
</div>
<Slideshow
items={post.album.photos.map(photo => ({
url: photo.url,
thumbnailUrl: photo.thumbnailUrl,
caption: photo.caption,
alt: photo.caption || post.album.title
}))}
alt={post.album.title}
aspectRatio="4/3"
/>
</div>
{:else if post.attachments && Array.isArray(post.attachments) && post.attachments.length > 0}
<!-- Regular attachments -->
<div class="post-attachments">
<h3>Photos</h3>
<Slideshow
items={post.attachments.map(attachment => ({
url: attachment.url,
thumbnailUrl: attachment.thumbnailUrl,
caption: attachment.caption,
alt: attachment.caption || 'Photo'
}))}
alt="Post photos"
aspectRatio="4/3"
/>
</div>
{/if}
@ -228,6 +250,7 @@
max-width: 600px;
}
.post-album,
.post-attachments {
margin-bottom: $unit-4x;
@ -237,26 +260,20 @@
margin: 0 0 $unit-2x;
color: $grey-20;
}
}
.attachments-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $unit-2x;
.album-header {
margin-bottom: $unit-3x;
h3 {
margin-bottom: $unit;
}
.attachment-item {
img {
width: 100%;
height: auto;
border-radius: $unit;
}
.attachment-caption {
margin: $unit 0 0;
font-size: 0.875rem;
color: $grey-40;
font-style: italic;
}
.album-description {
margin: 0;
font-size: 0.9rem;
color: $grey-40;
line-height: 1.5;
}
}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import Lightbox from './Lightbox.svelte'
import TiltCard from './TiltCard.svelte'
import Slideshow from './Slideshow.svelte'
let {
images = [],
@ -10,191 +9,9 @@
alt?: string
} = $props()
let selectedIndex = $state(0)
let lightboxOpen = $state(false)
let windowWidth = $state(0)
// Calculate columns based on breakpoints
const columnsPerRow = $derived(windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : 6)
const totalSlots = $derived(Math.ceil(images.length / columnsPerRow) * columnsPerRow)
const selectImage = (index: number) => {
selectedIndex = index
}
const openLightbox = (index?: number) => {
if (index !== undefined) {
selectedIndex = index
}
lightboxOpen = true
}
// Track window width for responsive columns
$effect(() => {
windowWidth = window.innerWidth
const handleResize = () => {
windowWidth = window.innerWidth
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
// Convert string array to slideshow items
const slideshowItems = $derived(images.map(url => ({ url, alt })))
</script>
{#if images.length === 1}
<!-- Single image -->
<TiltCard>
<button class="single-image image-button" onclick={() => openLightbox()}>
<img src={images[0]} {alt} />
</button>
</TiltCard>
{:else if images.length > 1}
<!-- Slideshow -->
<div class="slideshow">
<TiltCard>
<button class="main-image image-button" onclick={() => openLightbox()}>
<img src={images[selectedIndex]} alt="{alt} {selectedIndex + 1}" />
</button>
</TiltCard>
<div class="thumbnails">
{#each Array(totalSlots) as _, index}
{#if index < images.length}
<button
class="thumbnail"
class:active={index === selectedIndex}
onclick={() => selectImage(index)}
aria-label="View image {index + 1}"
>
<img src={images[index]} alt="{alt} thumbnail {index + 1}" />
</button>
{:else}
<div class="thumbnail placeholder" aria-hidden="true"></div>
{/if}
{/each}
</div>
</div>
{/if}
<Slideshow items={slideshowItems} {alt} aspectRatio="4/3" />
<Lightbox {images} bind:selectedIndex bind:isOpen={lightboxOpen} {alt} />
<style lang="scss">
.image-button {
border: none;
padding: 0;
background: none;
cursor: pointer;
display: block;
width: 100%;
&:focus {
outline: 2px solid $red-60;
outline-offset: 2px;
}
}
.single-image,
.main-image {
aspect-ratio: 4 / 3;
border-radius: $image-corner-radius;
overflow: hidden;
// Force GPU acceleration and proper clipping
transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.slideshow {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.thumbnails {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: $unit-2x;
@media (max-width: 600px) {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 400px) {
grid-template-columns: repeat(3, 1fr);
}
}
.thumbnail {
position: relative;
aspect-ratio: 1;
border-radius: $image-corner-radius;
overflow: hidden;
border: none;
padding: 0;
background: none;
cursor: pointer;
transition: all 0.2s ease;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: $image-corner-radius;
border: 4px solid transparent;
z-index: 2;
pointer-events: none;
transition: border-color 0.2s ease;
}
&::after {
content: '';
position: absolute;
inset: 4px;
border-radius: calc($image-corner-radius - 4px);
border: 4px solid transparent;
z-index: 3;
pointer-events: none;
transition: border-color 0.2s ease;
}
&:hover {
transform: scale(0.98);
}
&.active {
&::before {
border-color: $red-60;
}
&::after {
border-color: $grey-100;
}
}
&.placeholder {
background: $grey-90;
cursor: default;
&:hover {
transform: none;
}
}
img {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
z-index: 1;
}
}
</style>

View file

@ -0,0 +1,319 @@
<script lang="ts">
import Lightbox from './Lightbox.svelte'
import TiltCard from './TiltCard.svelte'
interface SlideItem {
url: string
thumbnailUrl?: string
caption?: string
alt?: string
}
let {
items = [],
alt = 'Image',
showThumbnails = true,
aspectRatio = '4/3',
maxThumbnails,
totalCount,
showMoreLink
}: {
items: SlideItem[]
alt?: string
showThumbnails?: boolean
aspectRatio?: string
maxThumbnails?: number
totalCount?: number
showMoreLink?: string
} = $props()
let selectedIndex = $state(0)
let lightboxOpen = $state(false)
let windowWidth = $state(0)
// Calculate columns based on breakpoints
const columnsPerRow = $derived(windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : 6)
const showMoreThumbnail = $derived(maxThumbnails && totalCount && totalCount > maxThumbnails - 1)
// Determine how many thumbnails to show
const displayItems = $derived(
!maxThumbnails || !showMoreThumbnail
? items
: items.slice(0, maxThumbnails - 1) // Show actual thumbnails, leave last slot for "+N"
)
const remainingCount = $derived(
showMoreThumbnail ? (totalCount || items.length) - (maxThumbnails - 1) : 0
)
const totalSlots = $derived(
maxThumbnails
? maxThumbnails
: Math.ceil((displayItems.length + (showMoreThumbnail ? 1 : 0)) / columnsPerRow) * columnsPerRow
)
// Convert items to image URLs for lightbox
const lightboxImages = $derived(items.map(item => item.url))
const selectImage = (index: number) => {
selectedIndex = index
}
const openLightbox = (index?: number) => {
if (index !== undefined) {
selectedIndex = index
}
lightboxOpen = true
}
// Track window width for responsive columns
$effect(() => {
windowWidth = window.innerWidth
const handleResize = () => {
windowWidth = window.innerWidth
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
</script>
{#if items.length === 1}
<!-- Single image -->
<TiltCard>
<div class="single-image image-container" onclick={() => openLightbox()}>
<img src={items[0].url} alt={items[0].alt || alt} />
{#if items[0].caption}
<div class="image-caption">{items[0].caption}</div>
{/if}
</div>
</TiltCard>
{:else if items.length > 1}
<!-- Slideshow -->
<div class="slideshow">
<TiltCard>
<div class="main-image image-container" onclick={() => openLightbox()}>
<img
src={items[selectedIndex].url}
alt={items[selectedIndex].alt || `${alt} ${selectedIndex + 1}`}
/>
{#if items[selectedIndex].caption}
<div class="image-caption">{items[selectedIndex].caption}</div>
{/if}
</div>
</TiltCard>
{#if showThumbnails}
<div class="thumbnails">
{#each Array(totalSlots) as _, index}
{#if index < displayItems.length}
<button
class="thumbnail"
class:active={index === selectedIndex}
onclick={() => selectImage(index)}
aria-label="View image {index + 1}"
>
<img
src={displayItems[index].thumbnailUrl || displayItems[index].url}
alt="{displayItems[index].alt || alt} thumbnail {index + 1}"
/>
</button>
{:else if index === displayItems.length && showMoreThumbnail}
<a
href={showMoreLink}
class="thumbnail show-more"
aria-label="View all {totalCount || items.length} photos"
>
{#if items[displayItems.length]}
<img
src={items[displayItems.length].thumbnailUrl || items[displayItems.length].url}
alt="View all photos"
class="blurred-bg"
/>
{:else if items[items.length - 1]}
<img
src={items[items.length - 1].thumbnailUrl || items[items.length - 1].url}
alt="View all photos"
class="blurred-bg"
/>
{/if}
<div class="show-more-overlay">
+{remainingCount}
</div>
</a>
{:else}
<div class="thumbnail placeholder" aria-hidden="true"></div>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
<Lightbox images={lightboxImages} bind:selectedIndex bind:isOpen={lightboxOpen} alt={alt} />
<style lang="scss">
.image-container {
cursor: pointer;
display: block;
width: 100%;
position: relative;
&:focus {
outline: 2px solid $red-60;
outline-offset: 2px;
}
}
.single-image,
.main-image {
width: 100%;
aspect-ratio: v-bind(aspectRatio);
border-radius: $image-corner-radius;
overflow: hidden;
display: flex;
// Force GPU acceleration and proper clipping
transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
flex-shrink: 0;
}
}
.image-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: $unit-3x $unit-2x $unit-2x;
font-size: 0.875rem;
line-height: 1.4;
}
.slideshow {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.thumbnails {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: $unit-2x;
@media (max-width: 600px) {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 400px) {
grid-template-columns: repeat(3, 1fr);
}
}
.thumbnail {
position: relative;
aspect-ratio: 1;
border-radius: $image-corner-radius;
overflow: hidden;
border: none;
padding: 0;
background: none;
cursor: pointer;
transition: all 0.2s ease;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: $image-corner-radius;
border: 4px solid transparent;
z-index: 2;
pointer-events: none;
transition: border-color 0.2s ease;
}
&::after {
content: '';
position: absolute;
inset: 4px;
border-radius: calc($image-corner-radius - 4px);
border: 4px solid transparent;
z-index: 3;
pointer-events: none;
transition: border-color 0.2s ease;
}
&:hover {
transform: scale(0.98);
}
&.active {
&::before {
border-color: $red-60;
}
&::after {
border-color: $grey-100;
}
}
&.placeholder {
background: $grey-90;
cursor: default;
&:hover {
transform: none;
}
}
&.show-more {
position: relative;
color: inherit;
text-decoration: none;
.blurred-bg {
filter: blur(3px);
transform: scale(1.1); // Slightly scale to hide blur edges
}
.show-more-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: 600;
border-radius: $image-corner-radius;
z-index: 2;
}
&:hover {
.show-more-overlay {
background: rgba(0, 0, 0, 0.7);
}
}
}
img {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
z-index: 1;
}
}
</style>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import UniverseIcon from '$icons/universe.svg'
import Slideshow from './Slideshow.svelte'
import type { UniverseItem } from '../../routes/api/universe/+server'
let { album }: { album: UniverseItem } = $props()
@ -12,6 +13,25 @@
year: 'numeric'
})
}
// Convert photos to slideshow items
const slideshowItems = $derived(
album.photos && album.photos.length > 0
? album.photos.map(photo => ({
url: photo.url,
thumbnailUrl: photo.thumbnailUrl,
caption: photo.caption,
alt: photo.caption || album.title
}))
: album.coverPhoto
? [{
url: album.coverPhoto.url,
thumbnailUrl: album.coverPhoto.thumbnailUrl,
caption: album.coverPhoto.caption,
alt: album.coverPhoto.caption || album.title
}]
: []
)
</script>
<article class="universe-album-card">
@ -23,16 +43,17 @@
</time>
</div>
{#if album.coverPhoto}
<div class="album-cover">
<img
src={album.coverPhoto.thumbnailUrl || album.coverPhoto.url}
alt={album.coverPhoto.caption || album.title}
loading="lazy"
{#if slideshowItems.length > 0}
<div class="album-slideshow">
<Slideshow
items={slideshowItems}
alt={album.title}
aspectRatio="3/2"
showThumbnails={slideshowItems.length > 1}
maxThumbnails={6}
totalCount={album.photosCount}
showMoreLink="/photos/{album.slug}"
/>
<div class="photo-count-overlay">
{album.photosCount || 0} photo{(album.photosCount || 0) !== 1 ? 's' : ''}
</div>
</div>
{/if}
@ -108,32 +129,10 @@
font-weight: 400;
}
.album-cover {
.album-slideshow {
position: relative;
width: 100%;
height: 200px;
border-radius: $unit;
overflow: hidden;
margin-bottom: $unit-3x;
background: $grey-95;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-count-overlay {
position: absolute;
bottom: $unit;
right: $unit;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: $unit-half $unit-2x;
border-radius: 50px;
font-size: 0.75rem;
font-weight: 500;
}
}
.album-info {

View file

@ -20,7 +20,18 @@ export const GET: RequestHandler = async (event) => {
id: true,
slug: true,
title: true,
description: true
description: true,
photos: {
orderBy: { displayOrder: 'asc' },
select: {
id: true,
url: true,
thumbnailUrl: true,
caption: true,
width: true,
height: true
}
}
}
},
photo: {

View file

@ -25,6 +25,7 @@ export interface UniverseItem {
date?: string
photosCount?: number
coverPhoto?: any
photos?: any[]
}
// GET /api/universe - Get mixed feed of published posts and albums
@ -74,13 +75,15 @@ export const GET: RequestHandler = async (event) => {
select: { photos: true }
},
photos: {
take: 1,
take: 6, // Fetch enough for 5 thumbnails + 1 background
orderBy: { displayOrder: 'asc' },
select: {
id: true,
url: true,
thumbnailUrl: true,
caption: true
caption: true,
width: true,
height: true
}
}
},
@ -114,7 +117,8 @@ export const GET: RequestHandler = async (event) => {
location: album.location || undefined,
date: album.date?.toISOString(),
photosCount: album._count.photos,
coverPhoto: album.photos[0] || null,
coverPhoto: album.photos[0] || null, // Keep for backward compatibility
photos: album.photos, // Add all photos for slideshow
publishedAt: album.createdAt.toISOString(), // Albums use createdAt as publishedAt
createdAt: album.createdAt.toISOString()
}))