Phot next/prev buttons follow you

This commit is contained in:
Justin Edmund 2025-06-13 07:39:24 -04:00
parent 00fc9b90cc
commit d1c7a777ed
3 changed files with 378 additions and 79 deletions

View file

@ -5,6 +5,7 @@
import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata' import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { spring } from 'svelte/motion'
import type { PageData } from './$types' import type { PageData } from './$types'
import ArrowLeft from '$icons/arrow-left.svg' import ArrowLeft from '$icons/arrow-left.svg'
import ArrowRight from '$icons/arrow-right.svg' import ArrowRight from '$icons/arrow-right.svg'
@ -16,6 +17,25 @@
const navigation = $derived(data.navigation) const navigation = $derived(data.navigation)
const error = $derived(data.error) const error = $derived(data.error)
// Hover tracking for arrow buttons
let isHoveringLeft = $state(false)
let isHoveringRight = $state(false)
// Spring stores for smooth button movement
const leftButtonCoords = spring({ x: 0, y: 0 }, {
stiffness: 0.3,
damping: 0.8
})
const rightButtonCoords = spring({ x: 0, y: 0 }, {
stiffness: 0.3,
damping: 0.8
})
// Default button positions (will be set once photo loads)
let defaultLeftX = 0
let defaultRightX = 0
const pageUrl = $derived($page.url.href) const pageUrl = $derived($page.url.href)
@ -63,6 +83,122 @@
}) })
: null : null
) )
// Set default button positions when component mounts
$effect(() => {
if (!photo) return
// Wait for DOM to update and image to load
const checkAndSetPositions = () => {
const pageContainer = document.querySelector('.photo-page') as HTMLElement
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
if (photoImage && photoImage.complete) {
const imageRect = photoImage.getBoundingClientRect()
const pageRect = pageContainer.getBoundingClientRect()
// Calculate default positions relative to the image
// Add 24px (half button width) since we're using translate(-50%, -50%)
defaultLeftX = (imageRect.left - pageRect.left) - 24 - 16 // half button width + gap
defaultRightX = (imageRect.right - pageRect.left) + 24 + 16 // half button width + gap
// Set initial positions at the vertical center of the image
const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2)
leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true })
rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true })
// Check if mouse is already in a hover zone
checkInitialMousePosition(pageContainer, imageRect, pageRect)
} else {
// If image not loaded yet, try again
setTimeout(checkAndSetPositions, 50)
}
}
checkAndSetPositions()
})
// We'll just remove the initial check for now
function checkInitialMousePosition(pageContainer: HTMLElement, imageRect: DOMRect, pageRect: DOMRect) {
// This will be handled by the first mouse move
}
// Mouse tracking for hover areas
function handleMouseMove(event: MouseEvent) {
const pageContainer = event.currentTarget as HTMLElement
const photoWrapper = pageContainer.querySelector('.photo-content-wrapper') as HTMLElement
if (!photoWrapper) return
// Get the actual image element inside PhotoView
const photoImage = photoWrapper.querySelector('img') as HTMLElement
if (!photoImage) return
const pageRect = pageContainer.getBoundingClientRect()
const photoRect = photoImage.getBoundingClientRect()
const x = event.clientX
const mouseX = event.clientX - pageRect.left
const mouseY = event.clientY - pageRect.top
// Check if mouse is in the left or right margin (outside the photo)
const wasHoveringLeft = isHoveringLeft
const wasHoveringRight = isHoveringRight
isHoveringLeft = x < photoRect.left
isHoveringRight = x > photoRect.right
// Calculate image center Y position
const imageCenterY = (photoRect.top - pageRect.top) + (photoRect.height / 2)
// Update button positions
if (isHoveringLeft) {
leftButtonCoords.set({ x: mouseX, y: mouseY })
} else if (wasHoveringLeft && !isHoveringLeft) {
// Reset left button to default
leftButtonCoords.set({ x: defaultLeftX, y: imageCenterY })
}
if (isHoveringRight) {
rightButtonCoords.set({ x: mouseX, y: mouseY })
} else if (wasHoveringRight && !isHoveringRight) {
// Reset right button to default
rightButtonCoords.set({ x: defaultRightX, y: imageCenterY })
}
}
function handleMouseLeave() {
isHoveringLeft = false
isHoveringRight = false
// Reset buttons to default positions
const pageContainer = document.querySelector('.photo-page') as HTMLElement
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
if (photoImage && pageContainer) {
const imageRect = photoImage.getBoundingClientRect()
const pageRect = pageContainer.getBoundingClientRect()
const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2)
leftButtonCoords.set({ x: defaultLeftX, y: centerY })
rightButtonCoords.set({ x: defaultRightX, y: centerY })
}
}
// Keyboard navigation
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft' && navigation?.prevPhoto) {
goto(`/photos/${album.slug}/${navigation.prevPhoto.id}`)
} else if (e.key === 'ArrowRight' && navigation?.nextPhoto) {
goto(`/photos/${album.slug}/${navigation.nextPhoto.id}`)
}
}
// Set up keyboard listener
$effect(() => {
window.addEventListener('keydown', handleKeydown)
return () => window.removeEventListener('keydown', handleKeydown)
})
</script> </script>
<svelte:head> <svelte:head>
@ -102,7 +238,11 @@
</div> </div>
</div> </div>
{:else} {:else}
<div class="photo-page"> <div
class="photo-page"
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
>
<div class="photo-content-wrapper"> <div class="photo-content-wrapper">
<PhotoView <PhotoView
src={photo.url} src={photo.url}
@ -110,31 +250,43 @@
title={photo.title} title={photo.title}
id={photo.id} id={photo.id}
/> />
</div>
<!-- Adjacent Photos Navigation --> <!-- Adjacent Photos Navigation -->
<div class="adjacent-navigation"> <div class="adjacent-navigation">
{#if navigation.prevPhoto} {#if navigation.prevPhoto}
<button <button
class="nav-button prev" class="nav-button prev"
onclick={() => goto(`/photos/${album.slug}/${navigation.prevPhoto.id}`)} class:hovering={isHoveringLeft}
type="button" style="
aria-label="Previous photo" left: {$leftButtonCoords.x}px;
> top: {$leftButtonCoords.y}px;
<ArrowLeft class="nav-icon" /> transform: translate(-50%, -50%);
</button> "
{/if} onclick={() => goto(`/photos/${album.slug}/${navigation.prevPhoto.id}`)}
type="button"
aria-label="Previous photo"
>
<ArrowLeft class="nav-icon" />
</button>
{/if}
{#if navigation.nextPhoto} {#if navigation.nextPhoto}
<button <button
class="nav-button next" class="nav-button next"
onclick={() => goto(`/photos/${album.slug}/${navigation.nextPhoto.id}`)} class:hovering={isHoveringRight}
type="button" style="
aria-label="Next photo" left: {$rightButtonCoords.x}px;
> top: {$rightButtonCoords.y}px;
<ArrowRight class="nav-icon" /> transform: translate(-50%, -50%);
</button> "
{/if} onclick={() => goto(`/photos/${album.slug}/${navigation.nextPhoto.id}`)}
</div> type="button"
aria-label="Next photo"
>
<ArrowRight class="nav-icon" />
</button>
{/if}
</div> </div>
<PhotoMetadata <PhotoMetadata
@ -209,7 +361,7 @@
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
box-sizing: border-box; justify-content: center;
} }
// Adjacent Navigation // Adjacent Navigation
@ -217,8 +369,8 @@
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: calc(-48px - #{$unit-2x}); left: 0;
right: calc(-48px - #{$unit-2x}); right: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -235,7 +387,7 @@
width: 48px; width: 48px;
height: 48px; height: 48px;
pointer-events: auto; pointer-events: auto;
position: relative; position: absolute;
border: none; border: none;
padding: 0; padding: 0;
background: $grey-100; background: $grey-100;
@ -244,13 +396,18 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s ease; transition: background 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover { &:hover {
background: $grey-95; background: $grey-95;
transform: scale(1.1); }
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
&.hovering {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
} }
&:focus-visible { &:focus-visible {
@ -269,13 +426,5 @@
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
} }
&.prev {
margin-right: auto;
}
&.next {
margin-left: auto;
}
} }
</style> </style>

View file

@ -6,6 +6,7 @@
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { spring } from 'svelte/motion'
import type { PageData } from './$types' import type { PageData } from './$types'
import { isAlbum } from '$lib/types/photos' import { isAlbum } from '$lib/types/photos'
import ArrowLeft from '$icons/arrow-left.svg' import ArrowLeft from '$icons/arrow-left.svg'
@ -18,6 +19,25 @@
const photoItems = $derived(data.photoItems || []) const photoItems = $derived(data.photoItems || [])
const currentPhotoId = $derived(data.currentPhotoId) const currentPhotoId = $derived(data.currentPhotoId)
// Hover tracking for arrow buttons
let isHoveringLeft = $state(false)
let isHoveringRight = $state(false)
// Spring stores for smooth button movement
const leftButtonCoords = spring({ x: 0, y: 0 }, {
stiffness: 0.3,
damping: 0.8
})
const rightButtonCoords = spring({ x: 0, y: 0 }, {
stiffness: 0.3,
damping: 0.8
})
// Default button positions (will be set once photo loads)
let defaultLeftX = 0
let defaultRightX = 0
const pageUrl = $derived($page.url.href) const pageUrl = $derived($page.url.href)
// Generate metadata // Generate metadata
@ -95,6 +115,107 @@
} }
} }
// Set default button positions when component mounts
$effect(() => {
if (!photo) return
// Wait for DOM to update and image to load
const checkAndSetPositions = () => {
const pageContainer = document.querySelector('.photo-page') as HTMLElement
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
if (photoImage && photoImage.complete) {
const imageRect = photoImage.getBoundingClientRect()
const pageRect = pageContainer.getBoundingClientRect()
// Calculate default positions relative to the image
// Add 24px (half button width) since we're using translate(-50%, -50%)
defaultLeftX = (imageRect.left - pageRect.left) - 24 - 16 // half button width + gap
defaultRightX = (imageRect.right - pageRect.left) + 24 + 16 // half button width + gap
// Set initial positions at the vertical center of the image
const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2)
leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true })
rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true })
// Check if mouse is already in a hover zone
checkInitialMousePosition(pageContainer, imageRect, pageRect)
} else {
// If image not loaded yet, try again
setTimeout(checkAndSetPositions, 50)
}
}
checkAndSetPositions()
})
// We'll just remove the initial check for now
function checkInitialMousePosition(pageContainer: HTMLElement, imageRect: DOMRect, pageRect: DOMRect) {
// This will be handled by the first mouse move
}
// Mouse tracking for hover areas
function handleMouseMove(event: MouseEvent) {
const pageContainer = event.currentTarget as HTMLElement
const photoWrapper = pageContainer.querySelector('.photo-content-wrapper') as HTMLElement
if (!photoWrapper) return
// Get the actual image element inside PhotoView
const photoImage = photoWrapper.querySelector('img') as HTMLElement
if (!photoImage) return
const pageRect = pageContainer.getBoundingClientRect()
const photoRect = photoImage.getBoundingClientRect()
const x = event.clientX
const mouseX = event.clientX - pageRect.left
const mouseY = event.clientY - pageRect.top
// Check if mouse is in the left or right margin (outside the photo)
const wasHoveringLeft = isHoveringLeft
const wasHoveringRight = isHoveringRight
isHoveringLeft = x < photoRect.left
isHoveringRight = x > photoRect.right
// Calculate image center Y position
const imageCenterY = (photoRect.top - pageRect.top) + (photoRect.height / 2)
// Update button positions
if (isHoveringLeft) {
leftButtonCoords.set({ x: mouseX, y: mouseY })
} else if (wasHoveringLeft && !isHoveringLeft) {
// Reset left button to default
leftButtonCoords.set({ x: defaultLeftX, y: imageCenterY })
}
if (isHoveringRight) {
rightButtonCoords.set({ x: mouseX, y: mouseY })
} else if (wasHoveringRight && !isHoveringRight) {
// Reset right button to default
rightButtonCoords.set({ x: defaultRightX, y: imageCenterY })
}
}
function handleMouseLeave() {
isHoveringLeft = false
isHoveringRight = false
// Reset buttons to default positions
const pageContainer = document.querySelector('.photo-page') as HTMLElement
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
if (photoImage && pageContainer) {
const imageRect = photoImage.getBoundingClientRect()
const pageRect = pageContainer.getBoundingClientRect()
const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2)
leftButtonCoords.set({ x: defaultLeftX, y: centerY })
rightButtonCoords.set({ x: defaultRightX, y: centerY })
}
}
// Set up keyboard listener // Set up keyboard listener
$effect(() => { $effect(() => {
window.addEventListener('keydown', handleKeydown) window.addEventListener('keydown', handleKeydown)
@ -134,7 +255,11 @@
</div> </div>
</div> </div>
{:else if photo} {:else if photo}
<div class="photo-page"> <div
class="photo-page"
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
>
<div class="photo-content-wrapper"> <div class="photo-content-wrapper">
<PhotoView <PhotoView
src={photo.url} src={photo.url}
@ -142,31 +267,43 @@
title={photo.title} title={photo.title}
id={photo.id} id={photo.id}
/> />
</div>
<!-- Adjacent Photos Navigation --> <!-- Adjacent Photos Navigation -->
<div class="adjacent-navigation"> <div class="adjacent-navigation">
{#if adjacentPhotos().prev} {#if adjacentPhotos().prev}
<button <button
class="nav-button prev" class="nav-button prev"
onclick={() => navigateToPhoto(adjacentPhotos().prev)} class:hovering={isHoveringLeft}
type="button" style="
aria-label="Previous photo" left: {$leftButtonCoords.x}px;
> top: {$leftButtonCoords.y}px;
<ArrowLeft class="nav-icon" /> transform: translate(-50%, -50%);
</button> "
{/if} onclick={() => navigateToPhoto(adjacentPhotos().prev)}
type="button"
aria-label="Previous photo"
>
<ArrowLeft class="nav-icon" />
</button>
{/if}
{#if adjacentPhotos().next} {#if adjacentPhotos().next}
<button <button
class="nav-button next" class="nav-button next"
onclick={() => navigateToPhoto(adjacentPhotos().next)} class:hovering={isHoveringRight}
type="button" style="
aria-label="Next photo" left: {$rightButtonCoords.x}px;
> top: {$rightButtonCoords.y}px;
<ArrowRight class="nav-icon" /> transform: translate(-50%, -50%);
</button> "
{/if} onclick={() => navigateToPhoto(adjacentPhotos().next)}
</div> type="button"
aria-label="Next photo"
>
<ArrowRight class="nav-icon" />
</button>
{/if}
</div> </div>
<PhotoMetadata <PhotoMetadata
@ -240,6 +377,7 @@
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
} }
@ -248,8 +386,8 @@
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: calc(-48px - #{$unit-2x}); left: 0;
right: calc(-48px - #{$unit-2x}); right: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -266,7 +404,7 @@
width: 48px; width: 48px;
height: 48px; height: 48px;
pointer-events: auto; pointer-events: auto;
position: relative; position: absolute;
border: none; border: none;
padding: 0; padding: 0;
background: $grey-100; background: $grey-100;
@ -275,13 +413,18 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s ease; transition: background 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover { &:hover {
background: $grey-95; background: $grey-95;
transform: scale(1.1); }
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
&.hovering {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
} }
&:focus-visible { &:focus-visible {
@ -300,13 +443,5 @@
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
} }
&.prev {
margin-right: auto;
}
&.next {
margin-left: auto;
}
} }
</style> </style>

15
test-db.ts Normal file
View file

@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function testDb() {
try {
const count = await prisma.media.count()
console.log('Total media entries:', count)
await prisma.$disconnect()
} catch (error) {
console.error('Database error:', error)
}
}
testDb()