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:
parent
3654a18cbe
commit
2bbc306762
27 changed files with 329 additions and 859 deletions
99
README.md
99
README.md
|
|
@ -58,3 +58,102 @@ Optional environment variables:
|
|||
- `npm run check` - Type check with svelte-check
|
||||
- `npm run lint` - Check formatting and linting
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@
|
|||
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||
|
||||
const {
|
||||
photoItems,
|
||||
albumSlug
|
||||
photoItems
|
||||
}: {
|
||||
photoItems: PhotoItemType[]
|
||||
albumSlug?: string
|
||||
} = $props()
|
||||
|
||||
// Responsive column configuration
|
||||
|
|
@ -55,7 +53,7 @@
|
|||
class="photo-masonry"
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<PhotoItem {item} {albumSlug} />
|
||||
<PhotoItem {item} />
|
||||
{/snippet}
|
||||
</Masonry>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@
|
|||
import { goto } from '$app/navigation'
|
||||
|
||||
const {
|
||||
item,
|
||||
albumSlug // For when this is used within an album context
|
||||
item
|
||||
}: {
|
||||
item: PhotoItem
|
||||
albumSlug?: string
|
||||
} = $props()
|
||||
|
||||
let imageLoaded = $state(false)
|
||||
|
|
@ -16,20 +14,11 @@
|
|||
function handleClick() {
|
||||
if (isAlbum(item)) {
|
||||
// Navigate to album page using the slug
|
||||
goto(`/photos/${item.slug}`)
|
||||
goto(`/albums/${item.slug}`)
|
||||
} else {
|
||||
// For individual photos, check if we have album context
|
||||
if (albumSlug) {
|
||||
// Navigate to photo within album
|
||||
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)
|
||||
}
|
||||
// Navigate to individual photo page using the media ID
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
|
||||
goto(`/photos/${mediaId}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@
|
|||
import { goto } from '$app/navigation'
|
||||
|
||||
const {
|
||||
photoItems,
|
||||
albumSlug
|
||||
photoItems
|
||||
}: {
|
||||
photoItems: PhotoItemType[]
|
||||
albumSlug?: string
|
||||
} = $props()
|
||||
|
||||
// Function to determine if an image is ultrawide (aspect ratio > 2:1)
|
||||
|
|
@ -98,18 +96,12 @@
|
|||
function handleClick(item: PhotoItemType) {
|
||||
if (isAlbum(item)) {
|
||||
// Navigate to album page using the slug
|
||||
goto(`/photos/${item.slug}`)
|
||||
goto(`/albums/${item.slug}`)
|
||||
} else {
|
||||
// For individual photos, check if we have album context
|
||||
if (albumSlug) {
|
||||
// Navigate to photo within album
|
||||
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
|
||||
goto(`/photos/p/${mediaId}`)
|
||||
}
|
||||
// Always navigate to individual photo page using the media ID
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
|
||||
goto(`/photos/${mediaId}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
showThumbnails={slideshowItems.length > 1}
|
||||
maxThumbnails={6}
|
||||
totalCount={album.photosCount}
|
||||
showMoreLink="/photos/{album.slug}"
|
||||
showMoreLink="/albums/{album.slug}"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
<div class="album-info">
|
||||
<h2 class="card-title">
|
||||
<a
|
||||
href="/photos/{album.slug}"
|
||||
href="/albums/{album.slug}"
|
||||
class="card-title-link"
|
||||
onclick={(e) => e.preventDefault()}
|
||||
tabindex="-1">{album.title}</a
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@
|
|||
dropdownActions={[
|
||||
{ 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}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { type Editor } from '@tiptap/core'
|
||||
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 { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js'
|
||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
||||
|
|
@ -370,13 +370,17 @@
|
|||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: selectedMedia.url,
|
||||
alt: selectedMedia.altText || '',
|
||||
title: selectedMedia.description || '',
|
||||
width: displayWidth,
|
||||
height: selectedMedia.height,
|
||||
align: 'center'
|
||||
.insertContent({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: selectedMedia.url,
|
||||
alt: selectedMedia.altText || '',
|
||||
title: selectedMedia.description || '',
|
||||
width: displayWidth,
|
||||
height: selectedMedia.height,
|
||||
align: 'center',
|
||||
mediaId: selectedMedia.id?.toString()
|
||||
}
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
|
@ -470,7 +474,15 @@
|
|||
|
||||
// Create a placeholder while uploading
|
||||
const placeholderSrc = URL.createObjectURL(file)
|
||||
editor.commands.setImage({ src: placeholderSrc })
|
||||
editor.commands.insertContent({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: placeholderSrc,
|
||||
alt: '',
|
||||
title: '',
|
||||
mediaId: null
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
|
|
@ -508,9 +520,11 @@
|
|||
attrs: {
|
||||
src: media.url,
|
||||
alt: media.filename || '',
|
||||
title: media.description || '',
|
||||
width: displayWidth,
|
||||
height: media.height,
|
||||
align: 'center'
|
||||
align: 'center',
|
||||
mediaId: media.id?.toString()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -350,14 +350,14 @@
|
|||
{/if}
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
<UnifiedMediaModal
|
||||
bind:isOpen={showModal}
|
||||
mode="multiple"
|
||||
fileType="image"
|
||||
{selectedIds}
|
||||
title="Add Images to Gallery"
|
||||
confirmText="Add Selected Images"
|
||||
onselect={handleImagesSelect}
|
||||
onSelect={handleImagesSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||
import MediaDetailsModal from './MediaDetailsModal.svelte'
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
|
@ -596,7 +596,7 @@
|
|||
/>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
<UnifiedMediaModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
mode="multiple"
|
||||
fileType="image"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -235,14 +235,14 @@
|
|||
{/if}
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
<UnifiedMediaModal
|
||||
bind:isOpen={showModal}
|
||||
{mode}
|
||||
{fileType}
|
||||
{selectedIds}
|
||||
title={modalTitle}
|
||||
{confirmText}
|
||||
onselect={handleMediaSelect}
|
||||
onSelect={handleMediaSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,18 @@ export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOp
|
|||
},
|
||||
align: {
|
||||
default: 'left'
|
||||
},
|
||||
mediaId: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-media-id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.mediaId) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-media-id': attributes.mediaId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -53,16 +53,22 @@
|
|||
const displayWidth =
|
||||
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
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: selectedMedia.url,
|
||||
alt: selectedMedia.altText || '',
|
||||
title: selectedMedia.description || '',
|
||||
width: displayWidth,
|
||||
height: selectedMedia.height,
|
||||
align: 'center'
|
||||
.insertContent({
|
||||
type: 'image',
|
||||
attrs: imageAttrs
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
|
@ -131,16 +137,23 @@
|
|||
// Set a reasonable default width (max 600px)
|
||||
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
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: media.url,
|
||||
alt: media.altText || '',
|
||||
title: media.description || '',
|
||||
width: displayWidth,
|
||||
height: media.height,
|
||||
align: 'center'
|
||||
.insertContent({
|
||||
type: 'image',
|
||||
attrs: imageAttrs
|
||||
})
|
||||
.run()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -31,10 +31,14 @@
|
|||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: selectedMedia.url,
|
||||
alt: selectedMedia.altText || '',
|
||||
title: selectedMedia.description || ''
|
||||
.insertContent({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: selectedMedia.url,
|
||||
alt: selectedMedia.altText || '',
|
||||
title: selectedMedia.description || '',
|
||||
mediaId: selectedMedia.id?.toString()
|
||||
}
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
|
@ -81,10 +85,14 @@
|
|||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: media.url,
|
||||
alt: media.altText || '',
|
||||
title: media.description || ''
|
||||
.insertContent({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: media.url,
|
||||
alt: media.altText || '',
|
||||
title: media.description || '',
|
||||
mediaId: media.id?.toString()
|
||||
}
|
||||
})
|
||||
.run()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,14 @@ export function getHandlePaste(editor: Editor, maxSize: number = 2) {
|
|||
reader.readAsDataURL(file)
|
||||
reader.onload = (e) => {
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,19 @@ export const renderEdraContent = (content: any): string => {
|
|||
const src = block.attrs?.src || block.src || ''
|
||||
const alt = block.attrs?.alt || block.alt || ''
|
||||
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':
|
||||
|
|
@ -146,7 +158,17 @@ function renderTiptapContent(doc: any): string {
|
|||
const height = node.attrs?.height
|
||||
const widthAttr = width ? ` width="${width}"` : ''
|
||||
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': {
|
||||
|
|
@ -361,3 +383,23 @@ function extractTiptapText(doc: any, maxLength: number): string {
|
|||
if (text.length <= maxLength) return text
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
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 type { Media } from '@prisma/client'
|
||||
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
}
|
||||
</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">
|
||||
<section class="test-section">
|
||||
<h2>Single Selection Mode</h2>
|
||||
|
|
@ -110,22 +110,22 @@
|
|||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<MediaLibraryModal
|
||||
<UnifiedMediaModal
|
||||
bind:isOpen={showSingleModal}
|
||||
mode="single"
|
||||
fileType="all"
|
||||
title="Select a Media File"
|
||||
confirmText="Select File"
|
||||
onselect={handleSingleSelect}
|
||||
onSelect={handleSingleSelect}
|
||||
/>
|
||||
|
||||
<MediaLibraryModal
|
||||
<UnifiedMediaModal
|
||||
bind:isOpen={showMultipleModal}
|
||||
mode="multiple"
|
||||
fileType="all"
|
||||
title="Select Media Files"
|
||||
confirmText="Select Files"
|
||||
onselect={handleMultipleSelect}
|
||||
onSelect={handleMultipleSelect}
|
||||
/>
|
||||
</AdminPage>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import { onMount } from '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 PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||
|
|
@ -396,7 +396,7 @@
|
|||
|
||||
{#if config?.showContent && contentReady}
|
||||
<div class="editor-wrapper">
|
||||
<Composer bind:data={content} placeholder="Continue writing..." />
|
||||
<EnhancedComposer bind:data={content} placeholder="Continue writing..." />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import { onMount } from '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 Button from '$lib/components/admin/Button.svelte'
|
||||
import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte'
|
||||
|
|
@ -199,7 +199,7 @@
|
|||
|
||||
{#if config?.showContent}
|
||||
<div class="editor-wrapper">
|
||||
<Composer bind:data={content} placeholder="Start writing..." />
|
||||
<EnhancedComposer bind:data={content} placeholder="Start writing..." />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@
|
|||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const type = $derived(data.type)
|
||||
const album = $derived(data.album)
|
||||
const photo = $derived(data.photo)
|
||||
const error = $derived(data.error)
|
||||
|
||||
// Transform album data to PhotoItem format for MasonryPhotoGrid
|
||||
|
|
@ -45,7 +43,7 @@
|
|||
|
||||
// Generate metadata
|
||||
const metaTags = $derived(
|
||||
type === 'album' && album
|
||||
album
|
||||
? generateMetaTags({
|
||||
title: album.title,
|
||||
description: album.content
|
||||
|
|
@ -58,20 +56,12 @@
|
|||
image: album.photos?.[0]?.url,
|
||||
titleFormat: { type: 'by' }
|
||||
})
|
||||
: type === 'photo' && photo
|
||||
? generateMetaTags({
|
||||
title: photo.title || 'Photo',
|
||||
description: photo.description || photo.caption || 'A photograph',
|
||||
url: pageUrl,
|
||||
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
|
||||
})
|
||||
: generateMetaTags({
|
||||
title: 'Not Found',
|
||||
description: 'The album you are looking for could not be found.',
|
||||
url: pageUrl,
|
||||
noindex: true
|
||||
})
|
||||
)
|
||||
|
||||
// Generate enhanced JSON-LD for albums with content
|
||||
|
|
@ -125,20 +115,7 @@
|
|||
}
|
||||
|
||||
// Generate image gallery JSON-LD
|
||||
const galleryJsonLd = $derived(
|
||||
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
|
||||
)
|
||||
const galleryJsonLd = $derived(album ? generateAlbumJsonLd(album, pageUrl) : null)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -174,7 +151,7 @@
|
|||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
{:else if type === 'album' && album}
|
||||
{:else if album}
|
||||
<div class="album-wrapper">
|
||||
<Page>
|
||||
{#snippet header()}
|
||||
|
|
@ -210,7 +187,7 @@
|
|||
<!-- Legacy Photo Grid (for albums without composed content) -->
|
||||
{#if photoItems.length > 0}
|
||||
<div class="legacy-photos">
|
||||
<MasonryPhotoGrid {photoItems} albumSlug={album.slug} />
|
||||
<MasonryPhotoGrid {photoItems} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-album">
|
||||
|
|
@ -434,9 +411,30 @@
|
|||
}
|
||||
|
||||
:global(figure img) {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
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) {
|
||||
29
src/routes/albums/[slug]/+page.ts
Normal file
29
src/routes/albums/[slug]/+page.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -54,10 +54,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
})
|
||||
logger.info('Album check', {
|
||||
albumId: album.id,
|
||||
status: fullAlbum?.status,
|
||||
isPhotography: fullAlbum?.isPhotography
|
||||
status: fullAlbum?.status
|
||||
})
|
||||
if (!fullAlbum || fullAlbum.status !== 'published' || !fullAlbum.isPhotography) {
|
||||
if (!fullAlbum || fullAlbum.status !== 'published') {
|
||||
logger.warn('Album not valid for public access', { albumId: album.id })
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -111,7 +111,7 @@
|
|||
if (!item) return
|
||||
// Extract media ID from item.id (could be 'media-123' or 'photo-123')
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '')
|
||||
goto(`/photos/p/${mediaId}`)
|
||||
goto(`/photos/${mediaId}`)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
|
|
@ -411,9 +411,9 @@
|
|||
createdAt={photo.createdAt}
|
||||
albums={photo.albums}
|
||||
backHref={fromAlbum
|
||||
? `/photos/${fromAlbum}`
|
||||
? `/albums/${fromAlbum}`
|
||||
: photo.album
|
||||
? `/photos/${photo.album.slug}`
|
||||
? `/albums/${photo.album.slug}`
|
||||
: '/photos'}
|
||||
backLabel={(() => {
|
||||
if (fromAlbum && photo.albums) {
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue