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 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
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', {
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
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) {
|
||||||
|
|
@ -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