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 { } else {
// Navigate to individual photo page using the media ID // Navigate to individual photo page using the media ID
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes 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 photo = $derived(isAlbum(item) ? item.coverPhoto : item)
const isAlbumItem = $derived(isAlbum(item)) const isAlbumItem = $derived(isAlbum(item))
const placeholderStyle = $derived( const placeholderStyle = $derived(photo.dominantColor ? `background: ${photo.dominantColor}` : '')
photo.dominantColor const aspectRatioStyle = $derived(photo.aspectRatio ? `aspect-ratio: ${photo.aspectRatio}` : '')
? `background: ${photo.dominantColor}`
: ''
)
const aspectRatioStyle = $derived(
photo.aspectRatio
? `aspect-ratio: ${photo.aspectRatio}`
: ''
)
</script> </script>
<div class="photo-item" class:is-album={isAlbumItem}> <div class="photo-item" class:is-album={isAlbumItem}>
@ -249,11 +243,9 @@
z-index: 1; z-index: 1;
overflow: hidden; overflow: hidden;
&.loaded { &.loaded {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
} }
</style> </style>

View file

@ -10,6 +10,7 @@
backHref?: string backHref?: string
backLabel?: string backLabel?: string
showBackButton?: boolean showBackButton?: boolean
albums?: Array<{ id: number; title: string; slug: string }>
class?: string class?: string
} }
@ -22,6 +23,7 @@
backHref, backHref,
backLabel, backLabel,
showBackButton = false, showBackButton = false,
albums = [],
class: className = '' class: className = ''
}: Props = $props() }: Props = $props()
@ -116,6 +118,19 @@
</div> </div>
{/if} {/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} {#if showBackButton && backHref && backLabel}
<div class="card-footer"> <div class="card-footer">
<BackButton href={backHref} label={backLabel} /> <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 { .card-footer {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -13,15 +13,7 @@
height?: number height?: number
} }
let { let { src, alt = '', title, id, class: className = '', width, height }: Props = $props()
src,
alt = '',
title,
id,
class: className = '',
width,
height
}: Props = $props()
let imageRef = $state<HTMLImageElement>() let imageRef = $state<HTMLImageElement>()
let isUltrawide = $state(false) let isUltrawide = $state(false)
@ -31,7 +23,12 @@
function checkIfUltrawide() { function checkIfUltrawide() {
if (width && height) { if (width && height) {
isUltrawide = width / height > 2 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) { } else if (imageRef && imageLoaded) {
isUltrawide = imageRef.naturalWidth / imageRef.naturalHeight > 2 isUltrawide = imageRef.naturalWidth / imageRef.naturalHeight > 2
console.log('Ultrawide check from image:', { console.log('Ultrawide check from image:', {
@ -72,12 +69,14 @@
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if (mutation.type === 'childList') { if (mutation.type === 'childList') {
// Check for zoom overlay - try multiple selectors // Check for zoom overlay - try multiple selectors
const zoomOverlay = document.querySelector('[data-smiz-overlay]') || const zoomOverlay =
document.querySelector('.medium-image-zoom-overlay') || document.querySelector('[data-smiz-overlay]') ||
document.querySelector('[data-rmiz-modal-overlay]') document.querySelector('.medium-image-zoom-overlay') ||
const zoomedImage = document.querySelector('[data-smiz-modal] img') || document.querySelector('[data-rmiz-modal-overlay]')
document.querySelector('.medium-image-zoom-image') || const zoomedImage =
document.querySelector('[data-rmiz-modal-img]') as HTMLImageElement 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:', { console.log('Checking for zoom elements:', {
zoomOverlay: !!zoomOverlay, zoomOverlay: !!zoomOverlay,
@ -88,10 +87,16 @@
// Also check for any new elements with specific classes // Also check for any new elements with specific classes
const allNewElements = mutation.addedNodes const allNewElements = mutation.addedNodes
allNewElements.forEach(node => { allNewElements.forEach((node) => {
if (node.nodeType === 1) { // Element node if (node.nodeType === 1) {
// Element node
const element = node as HTMLElement 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')
)
} }
}) })

View file

@ -28,7 +28,9 @@
// Initialize view mode from URL or default // Initialize view mode from URL or default
const urlMode = $page.url.searchParams.get('view') as ViewMode const urlMode = $page.url.searchParams.get('view') as ViewMode
let viewMode = $state<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 // Track loaded photo IDs to prevent duplicates

View file

@ -386,6 +386,7 @@
description={photo.description} description={photo.description}
{exifData} {exifData}
createdAt={photo.createdAt} createdAt={photo.createdAt}
albums={photo.albums}
backHref={`/photos/${album.slug}`} backHref={`/photos/${album.slug}`}
backLabel={`Back to ${album.title}`} backLabel={`Back to ${album.title}`}
showBackButton={true} showBackButton={true}

View file

@ -2,6 +2,7 @@
import MasonryPhotoGrid from '$components/MasonryPhotoGrid.svelte' import MasonryPhotoGrid from '$components/MasonryPhotoGrid.svelte'
import BackButton from '$components/BackButton.svelte' import BackButton from '$components/BackButton.svelte'
import { generateMetaTags, generateImageGalleryJsonLd } from '$lib/utils/metadata' import { generateMetaTags, generateImageGalleryJsonLd } from '$lib/utils/metadata'
import { renderEdraContent, getContentExcerpt } from '$lib/utils/content'
import { page } from '$app/stores' import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
@ -35,14 +36,23 @@
const pageUrl = $derived($page.url.href) 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 // Generate metadata
const metaTags = $derived( const metaTags = $derived(
type === 'album' && album type === 'album' && album
? generateMetaTags({ ? generateMetaTags({
title: album.title, title: album.title,
description: description: album.content
album.description || ? extractContentPreview(album.content) ||
`Photo album: ${album.title}${album.location ? ` taken in ${album.location}` : ''}`, album.description ||
`Photo story: ${album.title}`
: album.description ||
`Photo album: ${album.title}${album.location ? ` taken in ${album.location}` : ''}`,
url: pageUrl, url: pageUrl,
image: album.photos?.[0]?.url, image: album.photos?.[0]?.url,
titleFormat: { type: 'by' } 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 // Generate image gallery JSON-LD
const galleryJsonLd = $derived( const galleryJsonLd = $derived(
type === 'album' && album type === 'album' && album
? generateImageGalleryJsonLd({ ? generateAlbumJsonLd(album, pageUrl)
name: album.title,
description: album.description,
url: pageUrl,
images:
album.photos?.map((photo: any) => ({
url: photo.url,
caption: photo.caption
})) || []
})
: type === 'photo' && photo : type === 'photo' && photo
? { ? {
'@context': 'https://schema.org', '@context': 'https://schema.org',
@ -143,13 +194,22 @@
</div> </div>
</div> </div>
<!-- Photo Grid --> <!-- Album Content -->
{#if photoItems.length > 0} {#if album.content}
<MasonryPhotoGrid {photoItems} albumSlug={album.slug} /> <div class="album-content-wrapper">
{:else} <div class="edra-rendered-content">
<div class="empty-album"> {@html renderEdraContent(album.content)}
<p>This album doesn't contain any photos yet.</p> </div>
</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} {/if}
</div> </div>
{:else if type === 'photo' && photo} {:else if type === 'photo' && photo}
@ -286,6 +346,134 @@
color: $grey-40; 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 { .photo-page {
width: 100%; width: 100%;
max-width: 1200px; max-width: 1200px;

View file

@ -46,6 +46,7 @@
let defaultRightX = 0 let defaultRightX = 0
const pageUrl = $derived($page.url.href) const pageUrl = $derived($page.url.href)
const fromAlbum = $derived($page.url.searchParams.get('from'))
// Generate metadata // Generate metadata
const metaTags = $derived( const metaTags = $derived(
@ -408,8 +409,19 @@
description={photo.description} description={photo.description}
{exifData} {exifData}
createdAt={photo.createdAt} createdAt={photo.createdAt}
backHref={photo.album ? `/photos/${photo.album.slug}` : '/photos'} albums={photo.albums}
backLabel={photo.album ? `Back to ${photo.album.title}` : 'Back to Photos'} 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} showBackButton={true}
/> />
</div> </div>