feat(photos): enhance photo viewing with improved grids and metadata

- Add PhotoGrid component as base for photo grid layouts
- Update PhotoItem with color placeholder loading states
- Enhance PhotoMetadata display with better formatting
- Improve PhotoViewEnhanced with smoother transitions
- Update single and two-column grid layouts
- Fix photo routing for album-based photo URLs
- Add support for direct photo ID routes
- Improve photo page performance and loading states

Creates a more polished photo viewing experience.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-24 01:13:31 +01:00
parent cfde42c336
commit 02e41ed3d6
10 changed files with 467 additions and 86 deletions

View file

@ -0,0 +1,122 @@
<script lang="ts">
import SmartImage from './SmartImage.svelte'
import type { Media } from '@prisma/client'
interface Props {
photos: Media[]
layout?: 'masonry' | 'grid'
onPhotoClick?: (photo: Media) => void
class?: string
}
let { photos = [], layout = 'masonry', onPhotoClick, class: className = '' }: Props = $props()
function handlePhotoClick(photo: Media) {
if (onPhotoClick) {
onPhotoClick(photo)
}
}
</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}
</div>
{/each}
</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;
}
.grid-item {
break-inside: avoid;
}
}
.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;
}
}
.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);
}
}
:global(.grid-photo) {
width: 100%;
height: auto;
display: block;
}
</style>

View file

@ -26,7 +26,9 @@
} else {
// Navigate to individual photo page using the media ID
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
goto(`/photos/p/${mediaId}`)
// Include the album slug as a 'from' parameter if we're in an album context
const url = albumSlug ? `/photos/p/${mediaId}?from=${albumSlug}` : `/photos/p/${mediaId}`
goto(url)
}
}
}
@ -37,16 +39,8 @@
const photo = $derived(isAlbum(item) ? item.coverPhoto : item)
const isAlbumItem = $derived(isAlbum(item))
const placeholderStyle = $derived(
photo.dominantColor
? `background: ${photo.dominantColor}`
: ''
)
const aspectRatioStyle = $derived(
photo.aspectRatio
? `aspect-ratio: ${photo.aspectRatio}`
: ''
)
const placeholderStyle = $derived(photo.dominantColor ? `background: ${photo.dominantColor}` : '')
const aspectRatioStyle = $derived(photo.aspectRatio ? `aspect-ratio: ${photo.aspectRatio}` : '')
</script>
<div class="photo-item" class:is-album={isAlbumItem}>
@ -249,11 +243,9 @@
z-index: 1;
overflow: hidden;
&.loaded {
opacity: 0;
pointer-events: none;
}
}
</style>

View file

@ -10,6 +10,7 @@
backHref?: string
backLabel?: string
showBackButton?: boolean
albums?: Array<{ id: number; title: string; slug: string }>
class?: string
}
@ -22,6 +23,7 @@
backHref,
backLabel,
showBackButton = false,
albums = [],
class: className = ''
}: Props = $props()
@ -116,6 +118,19 @@
</div>
{/if}
{#if albums && albums.length > 0}
<div class="albums-section">
<h3 class="albums-title">This photo appears in:</h3>
<div class="albums-list">
{#each albums as album}
<a href="/photos/{album.slug}" class="album-link">
{album.title}
</a>
{/each}
</div>
</div>
{/if}
{#if showBackButton && backHref && backLabel}
<div class="card-footer">
<BackButton href={backHref} label={backLabel} />
@ -219,6 +234,50 @@
}
}
.albums-section {
margin-bottom: $unit-4x;
padding-bottom: $unit-4x;
border-bottom: 1px solid $grey-90;
@include breakpoint('phone') {
margin-bottom: $unit-3x;
padding-bottom: $unit-3x;
}
.albums-title {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $grey-40;
margin: 0 0 $unit-2x;
}
.albums-list {
display: flex;
flex-wrap: wrap;
gap: $unit $unit-2x;
}
.album-link {
font-size: 0.875rem;
color: $red-60;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: $red-50;
text-decoration: underline;
}
&:not(:last-child)::after {
content: ',';
color: $grey-40;
margin-left: 2px;
}
}
}
.card-footer {
display: flex;
justify-content: center;

View file

@ -13,15 +13,7 @@
height?: number
}
let {
src,
alt = '',
title,
id,
class: className = '',
width,
height
}: Props = $props()
let { src, alt = '', title, id, class: className = '', width, height }: Props = $props()
let imageRef = $state<HTMLImageElement>()
let isUltrawide = $state(false)
@ -31,14 +23,19 @@
function checkIfUltrawide() {
if (width && height) {
isUltrawide = width / height > 2
console.log('Ultrawide check from props:', { width, height, ratio: width / height, isUltrawide })
console.log('Ultrawide check from props:', {
width,
height,
ratio: width / height,
isUltrawide
})
} else if (imageRef && imageLoaded) {
isUltrawide = imageRef.naturalWidth / imageRef.naturalHeight > 2
console.log('Ultrawide check from image:', {
naturalWidth: imageRef.naturalWidth,
naturalHeight: imageRef.naturalHeight,
ratio: imageRef.naturalWidth / imageRef.naturalHeight,
isUltrawide
console.log('Ultrawide check from image:', {
naturalWidth: imageRef.naturalWidth,
naturalHeight: imageRef.naturalHeight,
ratio: imageRef.naturalWidth / imageRef.naturalHeight,
isUltrawide
})
}
}
@ -64,7 +61,7 @@
// Enhance zoom behavior for ultrawide images
function enhanceZoomForUltrawide() {
if (!isUltrawide) return
console.log('Setting up ultrawide zoom enhancement')
// Wait for zoom to be activated
@ -72,53 +69,61 @@
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check for zoom overlay - try multiple selectors
const zoomOverlay = document.querySelector('[data-smiz-overlay]') ||
document.querySelector('.medium-image-zoom-overlay') ||
document.querySelector('[data-rmiz-modal-overlay]')
const zoomedImage = document.querySelector('[data-smiz-modal] img') ||
document.querySelector('.medium-image-zoom-image') ||
document.querySelector('[data-rmiz-modal-img]') as HTMLImageElement
console.log('Checking for zoom elements:', {
zoomOverlay: !!zoomOverlay,
const zoomOverlay =
document.querySelector('[data-smiz-overlay]') ||
document.querySelector('.medium-image-zoom-overlay') ||
document.querySelector('[data-rmiz-modal-overlay]')
const zoomedImage =
document.querySelector('[data-smiz-modal] img') ||
document.querySelector('.medium-image-zoom-image') ||
(document.querySelector('[data-rmiz-modal-img]') as HTMLImageElement)
console.log('Checking for zoom elements:', {
zoomOverlay: !!zoomOverlay,
zoomedImage: !!zoomedImage,
allDivs: document.querySelectorAll('div').length,
bodyChildren: document.body.children.length
})
// Also check for any new elements with specific classes
const allNewElements = mutation.addedNodes
allNewElements.forEach(node => {
if (node.nodeType === 1) { // Element node
allNewElements.forEach((node) => {
if (node.nodeType === 1) {
// Element node
const element = node as HTMLElement
console.log('New element added:', element.tagName, element.className, element.getAttribute('data-rmiz-modal-overlay'))
console.log(
'New element added:',
element.tagName,
element.className,
element.getAttribute('data-rmiz-modal-overlay')
)
}
})
if (zoomOverlay && zoomedImage) {
console.log('Zoom activated, applying ultrawide enhancements')
// Add custom class for ultrawide handling
zoomOverlay.classList.add('ultrawide-zoom')
// Make the zoomed image scrollable horizontally
const modal = zoomedImage.closest('[data-smiz-modal]') as HTMLElement
if (modal) {
modal.style.overflow = 'auto'
modal.style.maxHeight = '90vh'
// Adjust image height to fill more vertical space for ultrawide
zoomedImage.style.maxHeight = '85vh'
zoomedImage.style.height = 'auto'
zoomedImage.style.width = 'auto'
zoomedImage.style.maxWidth = 'none'
// Center the scroll position initially
setTimeout(() => {
const scrollLeft = (modal.scrollWidth - modal.clientWidth) / 2
modal.scrollLeft = scrollLeft
updateScrollIndicators(modal)
}, 50)
// Add scroll listener to update indicators
modal.addEventListener('scroll', () => updateScrollIndicators(modal))
}
@ -156,10 +161,10 @@
<div class="photo-view {className}" class:ultrawide={isUltrawide}>
{#key id || src}
<Zoom>
<img
<img
bind:this={imageRef}
{src}
alt={title || alt || 'Photo'}
{src}
alt={title || alt || 'Photo'}
class="photo-image"
onload={handleImageLoad}
/>
@ -203,7 +208,7 @@
:global(.ultrawide-zoom) {
:global([data-smiz-modal]) {
cursor: grab;
&:active {
cursor: grabbing;
}
@ -267,4 +272,4 @@
}
}
}
</style>
</style>

View file

@ -57,4 +57,4 @@
padding: $unit 0;
}
}
</style>
</style>

View file

@ -33,7 +33,7 @@
display: grid;
grid-template-columns: 1fr 1fr;
gap: $unit-3x;
@include breakpoint('phone') {
gap: $unit-2x;
}
@ -43,9 +43,9 @@
display: flex;
flex-direction: column;
gap: $unit-3x;
@include breakpoint('phone') {
gap: $unit-2x;
}
}
</style>
</style>

View file

@ -28,7 +28,9 @@
// Initialize view mode from URL or default
const urlMode = $page.url.searchParams.get('view') as ViewMode
let viewMode = $state<ViewMode>(
urlMode && ['masonry', 'single', 'two-column', 'horizontal'].includes(urlMode) ? urlMode : 'two-column'
urlMode && ['masonry', 'single', 'two-column', 'horizontal'].includes(urlMode)
? urlMode
: 'two-column'
)
// Track loaded photo IDs to prevent duplicates

View file

@ -333,10 +333,10 @@
{:else}
<div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
<div class="photo-content-wrapper">
<PhotoViewEnhanced
src={photo.url}
alt={photo.caption}
title={photo.title}
<PhotoViewEnhanced
src={photo.url}
alt={photo.caption}
title={photo.title}
id={photo.id}
width={photo.width}
height={photo.height}
@ -386,6 +386,7 @@
description={photo.description}
{exifData}
createdAt={photo.createdAt}
albums={photo.albums}
backHref={`/photos/${album.slug}`}
backLabel={`Back to ${album.title}`}
showBackButton={true}

View file

@ -2,6 +2,7 @@
import MasonryPhotoGrid from '$components/MasonryPhotoGrid.svelte'
import BackButton from '$components/BackButton.svelte'
import { generateMetaTags, generateImageGalleryJsonLd } from '$lib/utils/metadata'
import { renderEdraContent, getContentExcerpt } from '$lib/utils/content'
import { page } from '$app/stores'
import type { PageData } from './$types'
@ -35,14 +36,23 @@
const pageUrl = $derived($page.url.href)
// Helper to get content preview using Edra content excerpt utility
const extractContentPreview = (content: any): string => {
if (!content) return ''
return getContentExcerpt(content, 155)
}
// Generate metadata
const metaTags = $derived(
type === 'album' && album
? generateMetaTags({
title: album.title,
description:
album.description ||
`Photo album: ${album.title}${album.location ? ` taken in ${album.location}` : ''}`,
description: album.content
? extractContentPreview(album.content) ||
album.description ||
`Photo story: ${album.title}`
: album.description ||
`Photo album: ${album.title}${album.location ? ` taken in ${album.location}` : ''}`,
url: pageUrl,
image: album.photos?.[0]?.url,
titleFormat: { type: 'by' }
@ -63,19 +73,60 @@
})
)
// Generate enhanced JSON-LD for albums with content
const generateAlbumJsonLd = (album: any, pageUrl: string) => {
const baseJsonLd = generateImageGalleryJsonLd({
name: album.title,
description: album.description,
url: pageUrl,
images:
album.photos?.map((photo: any) => ({
url: photo.url,
caption: photo.caption
})) || []
})
// Enhance with Article schema if album has composed content
if (album.content) {
return {
'@context': 'https://schema.org',
'@graph': [
baseJsonLd,
{
'@type': 'Article',
'@id': `${pageUrl}#article`,
headline: album.title,
description: album.description,
url: pageUrl,
datePublished: album.date || album.createdAt,
dateModified: album.updatedAt || album.createdAt,
author: {
'@type': 'Person',
name: 'Justin Edmund',
url: 'https://jedmund.com'
},
publisher: {
'@type': 'Person',
name: 'Justin Edmund',
url: 'https://jedmund.com'
},
image: album.photos?.[0]?.url,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': pageUrl
}
}
]
}
}
return baseJsonLd
}
// Generate image gallery JSON-LD
const galleryJsonLd = $derived(
type === 'album' && album
? generateImageGalleryJsonLd({
name: album.title,
description: album.description,
url: pageUrl,
images:
album.photos?.map((photo: any) => ({
url: photo.url,
caption: photo.caption
})) || []
})
? generateAlbumJsonLd(album, pageUrl)
: type === 'photo' && photo
? {
'@context': 'https://schema.org',
@ -143,13 +194,22 @@
</div>
</div>
<!-- Photo Grid -->
{#if photoItems.length > 0}
<MasonryPhotoGrid {photoItems} albumSlug={album.slug} />
{:else}
<div class="empty-album">
<p>This album doesn't contain any photos yet.</p>
<!-- Album Content -->
{#if album.content}
<div class="album-content-wrapper">
<div class="edra-rendered-content">
{@html renderEdraContent(album.content)}
</div>
</div>
{:else}
<!-- Legacy Photo Grid (for albums without composed content) -->
{#if photoItems.length > 0}
<MasonryPhotoGrid {photoItems} albumSlug={album.slug} />
{:else}
<div class="empty-album">
<p>This album doesn't contain any photos yet.</p>
</div>
{/if}
{/if}
</div>
{:else if type === 'photo' && photo}
@ -286,6 +346,134 @@
color: $grey-40;
}
.album-content-wrapper {
margin-top: $unit-4x;
@include breakpoint('phone') {
margin-top: $unit-3x;
}
}
.edra-rendered-content {
max-width: 700px;
margin: 0 auto;
:global(p) {
margin: 0 0 $unit-3x;
line-height: 1.7;
color: $grey-20;
}
:global(h1),
:global(h2),
:global(h3),
:global(h4) {
margin: $unit-4x 0 $unit-2x;
color: $grey-10;
font-weight: 600;
}
:global(h1) {
font-size: 2rem;
}
:global(h2) {
font-size: 1.5rem;
}
:global(h3) {
font-size: 1.25rem;
}
:global(h4) {
font-size: 1.125rem;
}
:global(ul),
:global(ol) {
margin: 0 0 $unit-3x;
padding-left: $unit-3x;
li {
margin-bottom: $unit;
line-height: 1.7;
}
}
:global(blockquote) {
margin: $unit-4x 0;
padding: $unit-3x;
background: $grey-97;
border-left: 4px solid $grey-80;
border-radius: $unit;
color: $grey-30;
font-style: italic;
}
:global(a) {
color: $red-60;
text-decoration: underline;
transition: color 0.2s ease;
&:hover {
color: $red-50;
}
}
:global(code) {
background: $grey-95;
padding: 2px 6px;
border-radius: 3px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.875em;
}
:global(pre) {
background: $grey-95;
padding: $unit-3x;
border-radius: $unit;
overflow-x: auto;
margin: 0 0 $unit-3x;
}
:global(hr) {
margin: $unit-4x 0;
border: none;
border-top: 1px solid $grey-90;
}
:global(figure) {
margin: $unit-4x 0;
text-align: center;
img {
max-width: 100%;
height: auto;
border-radius: $card-corner-radius;
}
figcaption {
margin-top: $unit;
font-size: 0.875rem;
color: $grey-40;
line-height: 1.5;
}
}
// Gallery styles
:global(.gallery-grid) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: $unit-2x;
margin: $unit-4x 0;
}
// Geolocation styles
:global(.geolocation-map) {
margin: $unit-4x 0;
border-radius: $card-corner-radius;
overflow: hidden;
height: 400px;
}
}
.photo-page {
width: 100%;
max-width: 1200px;

View file

@ -46,6 +46,7 @@
let defaultRightX = 0
const pageUrl = $derived($page.url.href)
const fromAlbum = $derived($page.url.searchParams.get('from'))
// Generate metadata
const metaTags = $derived(
@ -355,10 +356,10 @@
{:else if photo}
<div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
<div class="photo-content-wrapper">
<PhotoViewEnhanced
src={photo.url}
alt={photo.caption}
title={photo.title}
<PhotoViewEnhanced
src={photo.url}
alt={photo.caption}
title={photo.title}
id={photo.id}
width={photo.width}
height={photo.height}
@ -408,8 +409,19 @@
description={photo.description}
{exifData}
createdAt={photo.createdAt}
backHref={photo.album ? `/photos/${photo.album.slug}` : '/photos'}
backLabel={photo.album ? `Back to ${photo.album.title}` : 'Back to Photos'}
albums={photo.albums}
backHref={fromAlbum
? `/photos/${fromAlbum}`
: photo.album
? `/photos/${photo.album.slug}`
: '/photos'}
backLabel={(() => {
if (fromAlbum && photo.albums) {
const album = photo.albums.find((a) => a.slug === fromAlbum)
return album ? `Back to ${album.title}` : 'Back to Photos'
}
return photo.album ? `Back to ${photo.album.title}` : 'Back to Photos'
})()}
showBackButton={true}
/>
</div>