refactor: restructure routing - albums at /albums/[slug], photos at /photos/[id]

- Move album routes from /photos/[slug] to /albums/[slug]
- Simplify photo permalinks from /photos/p/[id] to /photos/[id]
- Remove album-scoped photo route /photos/[albumSlug]/[photoId]
- Update all component references to use new routes
- Simplify content.ts to always use direct photo permalinks
- Update PhotoItem, MasonryPhotoGrid, ThreeColumnPhotoGrid components
- Update UniverseAlbumCard and admin AlbumForm view links
- Remove album context from photo navigation

Breaking change: URLs have changed
- Albums: /photos/[slug] → /albums/[slug]
- Photos: /photos/p/[id] → /photos/[id]

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-24 10:35:21 +01:00
parent 3654a18cbe
commit 2bbc306762
27 changed files with 329 additions and 859 deletions

View file

@ -58,3 +58,102 @@ Optional environment variables:
- `npm run check` - Type check with svelte-check - `npm run check` - Type check with svelte-check
- `npm run lint` - Check formatting and linting - `npm run lint` - Check formatting and linting
- `npm run format` - Auto-format code with prettier - `npm run format` - Auto-format code with prettier
## Database Management
### Quick Start
Sync remote production database to local development:
```bash
# This backs up both databases first, then copies remote to local
npm run db:backup:sync
```
### Prerequisites
1. PostgreSQL client tools must be installed (`pg_dump`, `psql`)
```bash
# macOS
brew install postgresql
# Ubuntu/Debian
sudo apt-get install postgresql-client
```
2. Set environment variables in `.env` or `.env.local`:
```bash
# Required for local database operations
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
# Required for remote database operations (use one of these)
REMOTE_DATABASE_URL="postgresql://user:password@remote-host:5432/dbname"
DATABASE_URL_PRODUCTION="postgresql://user:password@remote-host:5432/dbname"
```
### Backup Commands
```bash
# Backup local database
npm run db:backup:local
# Backup remote database
npm run db:backup:remote
# Sync remote to local (recommended for daily development)
npm run db:backup:sync
# List all backups
npm run db:backups
```
### Restore Commands
```bash
# Restore a backup to local database (interactive)
npm run db:restore
# Restore specific backup to local
npm run db:restore ./backups/backup_file.sql.gz
# Restore to remote (requires typing "RESTORE REMOTE" for safety)
npm run db:restore ./backups/backup_file.sql.gz remote
```
### Common Workflows
#### Daily Development
Start your day by syncing the production database to local:
```bash
npm run db:backup:sync
```
#### Before Deploying Schema Changes
Always backup the remote database:
```bash
npm run db:backup:remote
```
#### Recover from Mistakes
```bash
# See available backups
npm run db:backups
# Restore a specific backup
npm run db:restore ./backups/local_20240615_143022.sql.gz
```
### Backup Storage
All backups are stored in `./backups/` with timestamps:
- Local: `local_YYYYMMDD_HHMMSS.sql.gz`
- Remote: `remote_YYYYMMDD_HHMMSS.sql.gz`
### Safety Features
1. **Automatic backups** before sync operations
2. **Confirmation prompts** for all destructive operations
3. **Extra protection** for remote restore (requires typing full phrase)
4. **Compressed storage** with gzip
5. **Timestamped filenames** prevent overwrites
6. **Automatic migrations** after local restore

View file

@ -4,11 +4,9 @@
import type { PhotoItem as PhotoItemType } from '$lib/types/photos' import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
const { const {
photoItems, photoItems
albumSlug
}: { }: {
photoItems: PhotoItemType[] photoItems: PhotoItemType[]
albumSlug?: string
} = $props() } = $props()
// Responsive column configuration // Responsive column configuration
@ -55,7 +53,7 @@
class="photo-masonry" class="photo-masonry"
> >
{#snippet children({ item })} {#snippet children({ item })}
<PhotoItem {item} {albumSlug} /> <PhotoItem {item} />
{/snippet} {/snippet}
</Masonry> </Masonry>
</div> </div>

View file

@ -4,11 +4,9 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
const { const {
item, item
albumSlug // For when this is used within an album context
}: { }: {
item: PhotoItem item: PhotoItem
albumSlug?: string
} = $props() } = $props()
let imageLoaded = $state(false) let imageLoaded = $state(false)
@ -16,20 +14,11 @@
function handleClick() { function handleClick() {
if (isAlbum(item)) { if (isAlbum(item)) {
// Navigate to album page using the slug // Navigate to album page using the slug
goto(`/photos/${item.slug}`) goto(`/albums/${item.slug}`)
} else { } else {
// For individual photos, check if we have album context // Navigate to individual photo page using the media ID
if (albumSlug) { const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
// Navigate to photo within album goto(`/photos/${mediaId}`)
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
// 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)
}
} }
} }

View file

@ -4,11 +4,9 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
const { const {
photoItems, photoItems
albumSlug
}: { }: {
photoItems: PhotoItemType[] photoItems: PhotoItemType[]
albumSlug?: string
} = $props() } = $props()
// Function to determine if an image is ultrawide (aspect ratio > 2:1) // Function to determine if an image is ultrawide (aspect ratio > 2:1)
@ -98,18 +96,12 @@
function handleClick(item: PhotoItemType) { function handleClick(item: PhotoItemType) {
if (isAlbum(item)) { if (isAlbum(item)) {
// Navigate to album page using the slug // Navigate to album page using the slug
goto(`/photos/${item.slug}`) goto(`/albums/${item.slug}`)
} else { } else {
// For individual photos, check if we have album context // For individual photos, check if we have album context
if (albumSlug) { // Always navigate to individual photo page using the media ID
// Navigate to photo within album const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes goto(`/photos/${mediaId}`)
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}`)
}
} }
} }

View file

@ -37,7 +37,7 @@
showThumbnails={slideshowItems.length > 1} showThumbnails={slideshowItems.length > 1}
maxThumbnails={6} maxThumbnails={6}
totalCount={album.photosCount} totalCount={album.photosCount}
showMoreLink="/photos/{album.slug}" showMoreLink="/albums/{album.slug}"
/> />
</div> </div>
{/if} {/if}
@ -45,7 +45,7 @@
<div class="album-info"> <div class="album-info">
<h2 class="card-title"> <h2 class="card-title">
<a <a
href="/photos/{album.slug}" href="/albums/{album.slug}"
class="card-title-link" class="card-title-link"
onclick={(e) => e.preventDefault()} onclick={(e) => e.preventDefault()}
tabindex="-1">{album.title}</a tabindex="-1">{album.title}</a

View file

@ -246,7 +246,7 @@
dropdownActions={[ dropdownActions={[
{ label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' } { label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' }
]} ]}
viewUrl={album?.slug ? `/photos/${album.slug}` : undefined} viewUrl={album?.slug ? `/albums/${album.slug}` : undefined}
/> />
{/if} {/if}
</div> </div>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { type Editor } from '@tiptap/core' import { type Editor } from '@tiptap/core'
import { onMount, setContext } from 'svelte' import { onMount, setContext } from 'svelte'
import { initiateEditor } from '$lib/components/edra/editor.js' import { initiateEditor } from '$lib/components/edra/editor.ts'
import { getEditorExtensions, EDITOR_PRESETS } from '$lib/components/edra/editor-extensions.js' import { getEditorExtensions, EDITOR_PRESETS } from '$lib/components/edra/editor-extensions.js'
import { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js' import { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js'
import LoaderCircle from 'lucide-svelte/icons/loader-circle' import LoaderCircle from 'lucide-svelte/icons/loader-circle'
@ -370,13 +370,17 @@
editor editor
.chain() .chain()
.focus() .focus()
.setImage({ .insertContent({
src: selectedMedia.url, type: 'image',
alt: selectedMedia.altText || '', attrs: {
title: selectedMedia.description || '', src: selectedMedia.url,
width: displayWidth, alt: selectedMedia.altText || '',
height: selectedMedia.height, title: selectedMedia.description || '',
align: 'center' width: displayWidth,
height: selectedMedia.height,
align: 'center',
mediaId: selectedMedia.id?.toString()
}
}) })
.run() .run()
} }
@ -470,7 +474,15 @@
// Create a placeholder while uploading // Create a placeholder while uploading
const placeholderSrc = URL.createObjectURL(file) const placeholderSrc = URL.createObjectURL(file)
editor.commands.setImage({ src: placeholderSrc }) editor.commands.insertContent({
type: 'image',
attrs: {
src: placeholderSrc,
alt: '',
title: '',
mediaId: null
}
})
try { try {
const auth = localStorage.getItem('admin_auth') const auth = localStorage.getItem('admin_auth')
@ -508,9 +520,11 @@
attrs: { attrs: {
src: media.url, src: media.url,
alt: media.filename || '', alt: media.filename || '',
title: media.description || '',
width: displayWidth, width: displayWidth,
height: media.height, height: media.height,
align: 'center' align: 'center',
mediaId: media.id?.toString()
} }
}) })

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from './Button.svelte' import Button from './Button.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte' import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
interface Props { interface Props {
@ -350,14 +350,14 @@
{/if} {/if}
<!-- Media Library Modal --> <!-- Media Library Modal -->
<MediaLibraryModal <UnifiedMediaModal
bind:isOpen={showModal} bind:isOpen={showModal}
mode="multiple" mode="multiple"
fileType="image" fileType="image"
{selectedIds} {selectedIds}
title="Add Images to Gallery" title="Add Images to Gallery"
confirmText="Add Selected Images" confirmText="Add Selected Images"
onselect={handleImagesSelect} onSelect={handleImagesSelect}
/> />
</div> </div>

View file

@ -7,7 +7,7 @@
import FormFieldWrapper from './FormFieldWrapper.svelte' import FormFieldWrapper from './FormFieldWrapper.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
import Input from './Input.svelte' import Input from './Input.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte' import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import MediaDetailsModal from './MediaDetailsModal.svelte' import MediaDetailsModal from './MediaDetailsModal.svelte'
import SmartImage from '../SmartImage.svelte' import SmartImage from '../SmartImage.svelte'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
@ -596,7 +596,7 @@
/> />
<!-- Media Library Modal --> <!-- Media Library Modal -->
<MediaLibraryModal <UnifiedMediaModal
bind:isOpen={isMediaLibraryOpen} bind:isOpen={isMediaLibraryOpen}
mode="multiple" mode="multiple"
fileType="image" fileType="image"

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from './Button.svelte' import Button from './Button.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte' import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
interface Props { interface Props {
@ -235,14 +235,14 @@
{/if} {/if}
<!-- Media Library Modal --> <!-- Media Library Modal -->
<MediaLibraryModal <UnifiedMediaModal
bind:isOpen={showModal} bind:isOpen={showModal}
{mode} {mode}
{fileType} {fileType}
{selectedIds} {selectedIds}
title={modalTitle} title={modalTitle}
{confirmText} {confirmText}
onselect={handleMediaSelect} onSelect={handleMediaSelect}
/> />
</div> </div>

View file

@ -24,6 +24,18 @@ export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOp
}, },
align: { align: {
default: 'left' default: 'left'
},
mediaId: {
default: null,
parseHTML: element => element.getAttribute('data-media-id'),
renderHTML: attributes => {
if (!attributes.mediaId) {
return {}
}
return {
'data-media-id': attributes.mediaId
}
}
} }
} }
}, },

View file

@ -53,16 +53,22 @@
const displayWidth = const displayWidth =
selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
const imageAttrs = {
src: selectedMedia.url,
alt: selectedMedia.altText || '',
title: selectedMedia.description || '',
width: displayWidth,
height: selectedMedia.height,
align: 'center',
mediaId: selectedMedia.id?.toString()
}
editor editor
.chain() .chain()
.focus() .focus()
.setImage({ .insertContent({
src: selectedMedia.url, type: 'image',
alt: selectedMedia.altText || '', attrs: imageAttrs
title: selectedMedia.description || '',
width: displayWidth,
height: selectedMedia.height,
align: 'center'
}) })
.run() .run()
} }
@ -131,16 +137,23 @@
// Set a reasonable default width (max 600px) // Set a reasonable default width (max 600px)
const displayWidth = media.width && media.width > 600 ? 600 : media.width const displayWidth = media.width && media.width > 600 ? 600 : media.width
const imageAttrs = {
src: media.url,
alt: media.altText || '',
title: media.description || '',
width: displayWidth,
height: media.height,
align: 'center',
mediaId: media.id?.toString()
}
editor editor
.chain() .chain()
.focus() .focus()
.setImage({ .insertContent({
src: media.url, type: 'image',
alt: media.altText || '', attrs: imageAttrs
title: media.description || '',
width: displayWidth,
height: media.height,
align: 'center'
}) })
.run() .run()
} else { } else {

View file

@ -31,10 +31,14 @@
editor editor
.chain() .chain()
.focus() .focus()
.setImage({ .insertContent({
src: selectedMedia.url, type: 'image',
alt: selectedMedia.altText || '', attrs: {
title: selectedMedia.description || '' src: selectedMedia.url,
alt: selectedMedia.altText || '',
title: selectedMedia.description || '',
mediaId: selectedMedia.id?.toString()
}
}) })
.run() .run()
} }
@ -81,10 +85,14 @@
editor editor
.chain() .chain()
.focus() .focus()
.setImage({ .insertContent({
src: media.url, type: 'image',
alt: media.altText || '', attrs: {
title: media.description || '' src: media.url,
alt: media.altText || '',
title: media.description || '',
mediaId: media.id?.toString()
}
}) })
.run() .run()
} else { } else {

View file

@ -93,7 +93,14 @@ export function getHandlePaste(editor: Editor, maxSize: number = 2) {
reader.readAsDataURL(file) reader.readAsDataURL(file)
reader.onload = (e) => { reader.onload = (e) => {
if (e.target?.result) { if (e.target?.result) {
editor.commands.setImage({ src: e.target.result as string }) editor.commands.insertContent({
type: 'image',
attrs: {
src: e.target.result as string,
alt: '',
mediaId: null // No media ID for pasted images
}
})
} }
} }
} }

View file

@ -63,7 +63,19 @@ export const renderEdraContent = (content: any): string => {
const src = block.attrs?.src || block.src || '' const src = block.attrs?.src || block.src || ''
const alt = block.attrs?.alt || block.alt || '' const alt = block.attrs?.alt || block.alt || ''
const caption = block.attrs?.caption || block.caption || '' const caption = block.attrs?.caption || block.caption || ''
return `<figure><img src="${src}" alt="${alt}" />${caption ? `<figcaption>${caption}</figcaption>` : ''}</figure>`
// Check if we have a media ID stored in attributes first
const mediaId = block.attrs?.mediaId || block.mediaId || extractMediaIdFromUrl(src)
if (mediaId) {
// Use album context for URL if available
const photoUrl = options.albumSlug
? `/photos/${options.albumSlug}/${mediaId}`
: `/photos/p/${mediaId}`
return `<figure class="interactive-figure"><a href="${photoUrl}" class="photo-link"><img src="${src}" alt="${alt}" /></a>${caption ? `<figcaption>${caption}</figcaption>` : ''}</figure>`
} else {
return `<figure><img src="${src}" alt="${alt}" />${caption ? `<figcaption>${caption}</figcaption>` : ''}</figure>`
}
} }
case 'hr': case 'hr':
@ -146,7 +158,17 @@ function renderTiptapContent(doc: any): string {
const height = node.attrs?.height const height = node.attrs?.height
const widthAttr = width ? ` width="${width}"` : '' const widthAttr = width ? ` width="${width}"` : ''
const heightAttr = height ? ` height="${height}"` : '' const heightAttr = height ? ` height="${height}"` : ''
return `<figure><img src="${src}" alt="${alt}"${widthAttr}${heightAttr} />${title ? `<figcaption>${title}</figcaption>` : ''}</figure>`
// Check if we have a media ID stored in attributes first
const mediaId = node.attrs?.mediaId || extractMediaIdFromUrl(src)
if (mediaId) {
// Always use direct photo permalink
const photoUrl = `/photos/${mediaId}`
return `<figure class="interactive-figure"><a href="${photoUrl}" class="photo-link"><img src="${src}" alt="${alt}"${widthAttr}${heightAttr} /></a>${title ? `<figcaption>${title}</figcaption>` : ''}</figure>`
} else {
return `<figure><img src="${src}" alt="${alt}"${widthAttr}${heightAttr} />${title ? `<figcaption>${title}</figcaption>` : ''}</figure>`
}
} }
case 'horizontalRule': { case 'horizontalRule': {
@ -361,3 +383,23 @@ function extractTiptapText(doc: any, maxLength: number): string {
if (text.length <= maxLength) return text if (text.length <= maxLength) return text
return text.substring(0, maxLength).trim() + '...' return text.substring(0, maxLength).trim() + '...'
} }
// Helper function to extract media ID from Cloudinary URL
function extractMediaIdFromUrl(url: string): string | null {
if (!url) return null
// Match Cloudinary URLs with media ID pattern
// Example: https://res.cloudinary.com/jedmund/image/upload/v1234567890/media/123.jpg
const cloudinaryMatch = url.match(/\/media\/(\d+)(?:\.|$)/)
if (cloudinaryMatch) {
return cloudinaryMatch[1]
}
// Fallback: try to extract numeric ID from filename
const filenameMatch = url.match(/\/(\d+)\.[^/]*$/)
if (filenameMatch) {
return filenameMatch[1]
}
return null
}

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import MediaLibraryModal from '$lib/components/admin/MediaLibraryModal.svelte' import UnifiedMediaModal from '$lib/components/admin/UnifiedMediaModal.svelte'
import Button from '$lib/components/admin/Button.svelte' import Button from '$lib/components/admin/Button.svelte'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
@ -36,7 +36,7 @@
} }
</script> </script>
<AdminPage title="Media Library Test" subtitle="Test the MediaLibraryModal component"> <AdminPage title="Media Library Test" subtitle="Test the UnifiedMediaModal component">
<div class="test-container"> <div class="test-container">
<section class="test-section"> <section class="test-section">
<h2>Single Selection Mode</h2> <h2>Single Selection Mode</h2>
@ -110,22 +110,22 @@
</div> </div>
<!-- Modals --> <!-- Modals -->
<MediaLibraryModal <UnifiedMediaModal
bind:isOpen={showSingleModal} bind:isOpen={showSingleModal}
mode="single" mode="single"
fileType="all" fileType="all"
title="Select a Media File" title="Select a Media File"
confirmText="Select File" confirmText="Select File"
onselect={handleSingleSelect} onSelect={handleSingleSelect}
/> />
<MediaLibraryModal <UnifiedMediaModal
bind:isOpen={showMultipleModal} bind:isOpen={showMultipleModal}
mode="multiple" mode="multiple"
fileType="all" fileType="all"
title="Select Media Files" title="Select Media Files"
confirmText="Select Files" confirmText="Select Files"
onselect={handleMultipleSelect} onSelect={handleMultipleSelect}
/> />
</AdminPage> </AdminPage>

View file

@ -3,7 +3,7 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Composer from '$lib/components/admin/Composer.svelte' import EnhancedComposer from '$lib/components/admin/EnhancedComposer.svelte'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte' import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte' import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte' import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
@ -396,7 +396,7 @@
{#if config?.showContent && contentReady} {#if config?.showContent && contentReady}
<div class="editor-wrapper"> <div class="editor-wrapper">
<Composer bind:data={content} placeholder="Continue writing..." /> <EnhancedComposer bind:data={content} placeholder="Continue writing..." />
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -3,7 +3,7 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Composer from '$lib/components/admin/Composer.svelte' import EnhancedComposer from '$lib/components/admin/EnhancedComposer.svelte'
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte' import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
import Button from '$lib/components/admin/Button.svelte' import Button from '$lib/components/admin/Button.svelte'
import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte' import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte'
@ -199,7 +199,7 @@
{#if config?.showContent} {#if config?.showContent}
<div class="editor-wrapper"> <div class="editor-wrapper">
<Composer bind:data={content} placeholder="Start writing..." /> <EnhancedComposer bind:data={content} placeholder="Start writing..." />
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -9,9 +9,7 @@
let { data }: { data: PageData } = $props() let { data }: { data: PageData } = $props()
const type = $derived(data.type)
const album = $derived(data.album) const album = $derived(data.album)
const photo = $derived(data.photo)
const error = $derived(data.error) const error = $derived(data.error)
// Transform album data to PhotoItem format for MasonryPhotoGrid // Transform album data to PhotoItem format for MasonryPhotoGrid
@ -45,7 +43,7 @@
// Generate metadata // Generate metadata
const metaTags = $derived( const metaTags = $derived(
type === 'album' && album album
? generateMetaTags({ ? generateMetaTags({
title: album.title, title: album.title,
description: album.content description: album.content
@ -58,20 +56,12 @@
image: album.photos?.[0]?.url, image: album.photos?.[0]?.url,
titleFormat: { type: 'by' } titleFormat: { type: 'by' }
}) })
: type === 'photo' && photo : generateMetaTags({
? generateMetaTags({ title: 'Not Found',
title: photo.title || 'Photo', description: 'The album you are looking for could not be found.',
description: photo.description || photo.caption || 'A photograph', url: pageUrl,
url: pageUrl, noindex: true
image: photo.url, })
titleFormat: { type: 'by' }
})
: generateMetaTags({
title: 'Not Found',
description: 'The content you are looking for could not be found.',
url: pageUrl,
noindex: true
})
) )
// Generate enhanced JSON-LD for albums with content // Generate enhanced JSON-LD for albums with content
@ -125,20 +115,7 @@
} }
// Generate image gallery JSON-LD // Generate image gallery JSON-LD
const galleryJsonLd = $derived( const galleryJsonLd = $derived(album ? generateAlbumJsonLd(album, pageUrl) : null)
type === 'album' && album
? generateAlbumJsonLd(album, pageUrl)
: type === 'photo' && photo
? {
'@context': 'https://schema.org',
'@type': 'ImageObject',
name: photo.title || 'Photo',
description: photo.description || photo.caption,
contentUrl: photo.url,
url: pageUrl
}
: null
)
</script> </script>
<svelte:head> <svelte:head>
@ -174,7 +151,7 @@
</div> </div>
</Page> </Page>
</div> </div>
{:else if type === 'album' && album} {:else if album}
<div class="album-wrapper"> <div class="album-wrapper">
<Page> <Page>
{#snippet header()} {#snippet header()}
@ -210,7 +187,7 @@
<!-- Legacy Photo Grid (for albums without composed content) --> <!-- Legacy Photo Grid (for albums without composed content) -->
{#if photoItems.length > 0} {#if photoItems.length > 0}
<div class="legacy-photos"> <div class="legacy-photos">
<MasonryPhotoGrid {photoItems} albumSlug={album.slug} /> <MasonryPhotoGrid {photoItems} />
</div> </div>
{:else} {:else}
<div class="empty-album"> <div class="empty-album">
@ -434,9 +411,30 @@
} }
:global(figure img) { :global(figure img) {
max-width: 100%; width: 100%;
height: auto; height: auto;
border-radius: $card-corner-radius; border-radius: $card-corner-radius;
display: block;
}
:global(figure.interactive-figure .photo-link) {
display: block;
text-decoration: none;
color: inherit;
outline: none;
cursor: pointer;
transition: transform 0.2s ease;
border-radius: $card-corner-radius;
overflow: hidden;
}
:global(figure.interactive-figure .photo-link:hover) {
transform: translateY(-2px);
}
:global(figure.interactive-figure .photo-link:focus-visible) {
outline: 2px solid $red-60;
outline-offset: 4px;
} }
:global(figure figcaption) { :global(figure figcaption) {

View file

@ -0,0 +1,29 @@
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params, fetch }) => {
try {
// Fetch album by slug
const albumResponse = await fetch(`/api/albums/by-slug/${params.slug}`)
if (albumResponse.ok) {
const album = await albumResponse.json()
// Check if album is published
if (album.status === 'published') {
return {
album
}
} else {
throw new Error('Album not published')
}
}
// Album not found
throw new Error('Album not found')
} catch (error) {
console.error('Error loading album:', error)
return {
album: null,
error: error instanceof Error ? error.message : 'Failed to load album'
}
}
}

View file

@ -1,126 +0,0 @@
import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
// GET /api/photos/[albumSlug]/[photoId] - Get individual photo with album context
export const GET: RequestHandler = async (event) => {
const albumSlug = event.params.albumSlug
const mediaId = parseInt(event.params.photoId) // Still called photoId in URL for compatibility
if (!albumSlug || isNaN(mediaId)) {
return errorResponse('Invalid album slug or media ID', 400)
}
try {
// First find the album with its media
const album = await prisma.album.findUnique({
where: {
slug: albumSlug,
status: 'published',
isPhotography: true
},
include: {
media: {
orderBy: { displayOrder: 'asc' },
include: {
media: {
select: {
id: true,
filename: true,
url: true,
thumbnailUrl: true,
width: true,
height: true,
photoCaption: true,
photoTitle: true,
photoDescription: true,
exifData: true,
createdAt: true,
photoPublishedAt: true
}
}
}
}
}
})
if (!album) {
return errorResponse('Album not found', 404)
}
// Find the specific media
const albumMediaIndex = album.media.findIndex((am) => am.media.id === mediaId)
if (albumMediaIndex === -1) {
return errorResponse('Photo not found in album', 404)
}
const albumMedia = album.media[albumMediaIndex]
const media = albumMedia.media
// Get navigation info
const prevMedia = albumMediaIndex > 0 ? album.media[albumMediaIndex - 1].media : null
const nextMedia =
albumMediaIndex < album.media.length - 1 ? album.media[albumMediaIndex + 1].media : null
// Fetch all albums this photo belongs to
const mediaWithAlbums = await prisma.media.findUnique({
where: { id: mediaId },
include: {
albums: {
include: {
album: {
select: { id: true, title: true, slug: true }
}
},
where: {
album: {
status: 'published',
isPhotography: true
}
}
}
}
})
// Transform to photo format for compatibility
const photo = {
id: media.id,
filename: media.filename,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
caption: media.photoCaption,
title: media.photoTitle,
description: media.photoDescription,
displayOrder: albumMedia.displayOrder,
exifData: media.exifData,
createdAt: media.createdAt,
publishedAt: media.photoPublishedAt,
albums: mediaWithAlbums?.albums.map((am) => am.album) || []
}
return jsonResponse({
photo,
album: {
id: album.id,
slug: album.slug,
title: album.title,
description: album.description,
location: album.location,
date: album.date,
totalPhotos: album.media.length
},
navigation: {
currentIndex: albumMediaIndex + 1, // 1-based for display
totalCount: album.media.length,
prevPhoto: prevMedia ? { id: prevMedia.id, url: prevMedia.thumbnailUrl } : null,
nextPhoto: nextMedia ? { id: nextMedia.id, url: nextMedia.thumbnailUrl } : null
}
})
} catch (error) {
logger.error('Failed to retrieve photo', error as Error)
return errorResponse('Failed to retrieve photo', 500)
}
}

View file

@ -54,10 +54,9 @@ export const GET: RequestHandler = async (event) => {
}) })
logger.info('Album check', { logger.info('Album check', {
albumId: album.id, albumId: album.id,
status: fullAlbum?.status, status: fullAlbum?.status
isPhotography: fullAlbum?.isPhotography
}) })
if (!fullAlbum || fullAlbum.status !== 'published' || !fullAlbum.isPhotography) { if (!fullAlbum || fullAlbum.status !== 'published') {
logger.warn('Album not valid for public access', { albumId: album.id }) logger.warn('Album not valid for public access', { albumId: album.id })
return errorResponse('Photo not found', 404) return errorResponse('Photo not found', 404)
} }

View file

@ -1,524 +0,0 @@
<script lang="ts">
import BackButton from '$components/BackButton.svelte'
import PhotoViewEnhanced from '$components/PhotoViewEnhanced.svelte'
import PhotoMetadata from '$components/PhotoMetadata.svelte'
import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { spring } from 'svelte/motion'
import { getCurrentMousePosition } from '$lib/stores/mouse'
import type { PageData } from './$types'
import ArrowLeft from '$icons/arrow-left.svg'
import ArrowRight from '$icons/arrow-right.svg'
let { data }: { data: PageData } = $props()
const photo = $derived(data.photo)
const album = $derived(data.album)
const navigation = $derived(data.navigation)
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)
// Parse EXIF data if available
const exifData = $derived(
photo?.exifData && typeof photo.exifData === 'object' ? photo.exifData : null
)
// Generate metadata
const photoTitle = $derived(photo?.title || photo?.caption || `Photo ${navigation?.currentIndex}`)
const photoDescription = $derived(
photo?.description || photo?.caption || `Photo from ${album?.title || 'album'}`
)
const metaTags = $derived(
photo && album
? generateMetaTags({
title: photoTitle,
description: photoDescription,
url: pageUrl,
type: 'article',
image: photo.url,
publishedTime: exifData?.dateTaken,
author: 'Justin Edmund',
titleFormat: { type: 'snippet', snippet: photoDescription }
})
: generateMetaTags({
title: 'Photo Not Found',
description: 'The photo you are looking for could not be found.',
url: pageUrl,
noindex: true
})
)
// Generate creative work JSON-LD
const photoJsonLd = $derived(
photo && album
? generateCreativeWorkJsonLd({
name: photoTitle,
description: photoDescription,
url: pageUrl,
image: photo.url,
creator: 'Justin Edmund',
dateCreated: exifData?.dateTaken,
keywords: ['photography', album.title, ...(exifData?.location ? [exifData.location] : [])]
})
: 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
// Small delay to ensure mouse store is initialized
setTimeout(() => {
checkInitialMousePosition(pageContainer, imageRect, pageRect)
}, 10)
} else {
// If image not loaded yet, try again
setTimeout(checkAndSetPositions, 50)
}
}
checkAndSetPositions()
})
// Check mouse position on load
function checkInitialMousePosition(
pageContainer: HTMLElement,
imageRect: DOMRect,
pageRect: DOMRect
) {
// Get current mouse position from store
const currentPos = getCurrentMousePosition()
// If no mouse position tracked yet, try to trigger one
if (currentPos.x === 0 && currentPos.y === 0) {
// Set up a one-time listener for the first mouse move
const handleFirstMove = (e: MouseEvent) => {
const x = e.clientX
const mouseX = e.clientX - pageRect.left
const mouseY = e.clientY - pageRect.top
// Check if mouse is in hover zones
if (x < imageRect.left) {
isHoveringLeft = true
leftButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
} else if (x > imageRect.right) {
isHoveringRight = true
rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
}
// Remove the listener
window.removeEventListener('mousemove', handleFirstMove)
}
window.addEventListener('mousemove', handleFirstMove)
return
}
// We have a mouse position, check if it's in a hover zone
const x = currentPos.x
const mouseX = currentPos.x - pageRect.left
const mouseY = currentPos.y - pageRect.top
// Store client coordinates for scroll updates
lastClientX = currentPos.x
lastClientY = currentPos.y
// Check if mouse is in hover zones
if (x < imageRect.left) {
isHoveringLeft = true
leftButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
} else if (x > imageRect.right) {
isHoveringRight = true
rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
}
}
// Store last mouse client position for scroll updates
let lastClientX = 0
let lastClientY = 0
// Update button positions during scroll
function handleScroll() {
if (!isHoveringLeft && !isHoveringRight) return
const pageContainer = document.querySelector('.photo-page') as HTMLElement
if (!pageContainer) return
// Use last known mouse position (which is viewport-relative)
// and recalculate relative to the page container's new position
const pageRect = pageContainer.getBoundingClientRect()
const mouseX = lastClientX - pageRect.left
const mouseY = lastClientY - pageRect.top
// Update button positions
if (isHoveringLeft) {
leftButtonCoords.set({ x: mouseX, y: mouseY })
}
if (isHoveringRight) {
rightButtonCoords.set({ x: mouseX, y: mouseY })
}
}
// 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
// Store last mouse position for scroll updates
lastClientX = event.clientX
lastClientY = event.clientY
// 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 and scroll listeners
$effect(() => {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('scroll', handleScroll)
}
})
</script>
<svelte:head>
<title>{metaTags.title}</title>
<meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Other meta tags -->
{#if metaTags.other.canonical}
<link rel="canonical" href={metaTags.other.canonical} />
{/if}
{#if metaTags.other.robots}
<meta name="robots" content={metaTags.other.robots} />
{/if}
<!-- JSON-LD -->
{#if photoJsonLd}
{@html `<script type="application/ld+json">${JSON.stringify(photoJsonLd)}</script>`}
{/if}
</svelte:head>
{#if error || !photo || !album}
<div class="error-container">
<div class="error-message">
<h1>Photo Not Found</h1>
<p>{error || "The photo you're looking for doesn't exist."}</p>
<BackButton href="/photos" label="Back to Photos" />
</div>
</div>
{:else}
<div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
<div class="photo-content-wrapper">
<PhotoViewEnhanced
src={photo.url}
alt={photo.caption}
title={photo.title}
id={photo.id}
width={photo.width}
height={photo.height}
/>
</div>
<!-- Adjacent Photos Navigation -->
<div class="adjacent-navigation">
{#if navigation.prevPhoto}
<button
class="nav-button prev"
class:hovering={isHoveringLeft}
style="
left: {$leftButtonCoords.x}px;
top: {$leftButtonCoords.y}px;
transform: translate(-50%, -50%);
"
onclick={() => goto(`/photos/${album.slug}/${navigation.prevPhoto.id}`)}
type="button"
aria-label="Previous photo"
>
<ArrowLeft class="nav-icon" />
</button>
{/if}
{#if navigation.nextPhoto}
<button
class="nav-button next"
class:hovering={isHoveringRight}
style="
left: {$rightButtonCoords.x}px;
top: {$rightButtonCoords.y}px;
transform: translate(-50%, -50%);
"
onclick={() => goto(`/photos/${album.slug}/${navigation.nextPhoto.id}`)}
type="button"
aria-label="Next photo"
>
<ArrowRight class="nav-icon" />
</button>
{/if}
</div>
<PhotoMetadata
title={photo.title}
caption={photo.caption}
description={photo.description}
{exifData}
createdAt={photo.createdAt}
albums={photo.albums}
backHref={`/photos/${album.slug}`}
backLabel={`Back to ${album.title}`}
showBackButton={true}
/>
</div>
{/if}
<style lang="scss">
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.error-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: $unit-6x $unit-3x;
}
.error-message {
text-align: center;
max-width: 500px;
h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 $unit-2x;
color: $red-60;
}
p {
margin: 0 0 $unit-3x;
color: $grey-40;
line-height: 1.5;
}
}
.photo-page {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 $unit-3x $unit-4x;
align-items: center;
display: flex;
flex-direction: column;
gap: $unit-2x;
box-sizing: border-box;
position: relative;
@include breakpoint('tablet') {
max-width: 900px;
}
@include breakpoint('phone') {
padding: 0 $unit-2x $unit-2x;
gap: $unit;
}
}
.photo-content-wrapper {
position: relative;
max-width: 700px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
// Adjacent Navigation
.adjacent-navigation {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
pointer-events: none;
z-index: 100;
// Hide on mobile and tablet
@include breakpoint('tablet') {
display: none;
}
}
.nav-button {
width: 48px;
height: 48px;
pointer-events: auto;
position: absolute;
border: none;
padding: 0;
background: $grey-100;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.2s ease,
box-shadow 0.2s ease;
&:hover {
background: $grey-95;
}
&.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 {
outline: none;
box-shadow:
0 0 0 3px $red-60,
0 0 0 5px $grey-100;
}
:global(svg) {
stroke: $grey-10;
width: 16px;
height: 16px;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
}
</style>

View file

@ -1,38 +0,0 @@
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params, fetch }) => {
try {
const { albumSlug, photoId } = params
const mediaId = parseInt(photoId)
if (isNaN(mediaId)) {
throw new Error('Invalid photo ID')
}
// Fetch the photo and album data with navigation
const response = await fetch(`/api/photos/${albumSlug}/${mediaId}`)
if (!response.ok) {
if (response.status === 404) {
throw new Error('Photo or album not found')
}
throw new Error('Failed to fetch photo')
}
const data = await response.json()
return {
photo: data.photo,
album: data.album,
navigation: data.navigation
}
} catch (error) {
console.error('Error loading photo:', error)
return {
photo: null,
album: null,
navigation: null,
error: error instanceof Error ? error.message : 'Failed to load photo'
}
}
}

View file

@ -111,7 +111,7 @@
if (!item) return if (!item) return
// Extract media ID from item.id (could be 'media-123' or 'photo-123') // Extract media ID from item.id (could be 'media-123' or 'photo-123')
const mediaId = item.id.replace(/^(media|photo)-/, '') const mediaId = item.id.replace(/^(media|photo)-/, '')
goto(`/photos/p/${mediaId}`) goto(`/photos/${mediaId}`)
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
@ -411,9 +411,9 @@
createdAt={photo.createdAt} createdAt={photo.createdAt}
albums={photo.albums} albums={photo.albums}
backHref={fromAlbum backHref={fromAlbum
? `/photos/${fromAlbum}` ? `/albums/${fromAlbum}`
: photo.album : photo.album
? `/photos/${photo.album.slug}` ? `/albums/${photo.album.slug}`
: '/photos'} : '/photos'}
backLabel={(() => { backLabel={(() => {
if (fromAlbum && photo.albums) { if (fromAlbum && photo.albums) {

View file

@ -1,42 +0,0 @@
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params, fetch }) => {
try {
// First try to fetch as an album
const albumResponse = await fetch(`/api/albums/by-slug/${params.slug}`)
if (albumResponse.ok) {
const album = await albumResponse.json()
// Check if album is published
if (album.status === 'published') {
return {
type: 'album' as const,
album,
photo: null
}
}
}
// If not found as album or not a photography album, try as individual photo
const photoResponse = await fetch(`/api/photos/by-slug/${params.slug}`)
if (photoResponse.ok) {
const photo = await photoResponse.json()
return {
type: 'photo' as const,
album: null,
photo
}
}
// Neither album nor photo found
throw new Error('Content not found')
} catch (error) {
console.error('Error loading content:', error)
return {
type: null,
album: null,
photo: null,
error: error instanceof Error ? error.message : 'Failed to load content'
}
}
}