Better single photo view

This commit is contained in:
Justin Edmund 2025-06-13 03:47:52 -04:00
parent 8bc9b9e1e4
commit 610a421207
25 changed files with 2540 additions and 468 deletions

View file

@ -0,0 +1,187 @@
# PRD: Dominant Color Extraction for Uploaded Images
## Overview
This PRD outlines the implementation of automatic dominant color extraction for images uploaded to the media library. This feature will analyze uploaded images to extract their primary colors, enabling color-based organization, search, and visual enhancements throughout the application.
## Goals
1. **Automatic Color Analysis**: Extract dominant colors from images during the upload process
2. **Data Storage**: Store color information efficiently alongside existing image metadata
3. **Visual Enhancement**: Use extracted colors to enhance UI/UX in galleries and image displays
4. **Performance**: Ensure color extraction doesn't significantly impact upload performance
## Technical Approach
### Color Extraction Library Options
1. **node-vibrant** (Recommended)
- Pros: Lightweight, fast, good algorithm, actively maintained
- Cons: Node.js only (server-side processing)
- NPM: `node-vibrant`
2. **color-thief-node**
- Pros: Simple API, battle-tested algorithm
- Cons: Less feature-rich than vibrant
- NPM: `colorthief`
3. **Cloudinary Color Analysis**
- Pros: Integrated with existing upload pipeline, no extra processing
- Cons: Requires paid plan, vendor lock-in
- API: `colors` parameter in upload response
### Recommended Approach: node-vibrant
```javascript
import Vibrant from 'node-vibrant'
// Extract colors from uploaded image
const palette = await Vibrant.from(buffer).getPalette()
const dominantColors = {
vibrant: palette.Vibrant?.hex,
darkVibrant: palette.DarkVibrant?.hex,
lightVibrant: palette.LightVibrant?.hex,
muted: palette.Muted?.hex,
darkMuted: palette.DarkMuted?.hex,
lightMuted: palette.LightMuted?.hex
}
```
## Database Schema Changes
### Option 1: Add to Existing exifData JSON (Recommended)
```prisma
model Media {
// ... existing fields
exifData Json? // Add color data here: { colors: { vibrant, muted, etc }, ...existingExif }
}
```
### Option 2: Separate Colors Field
```prisma
model Media {
// ... existing fields
dominantColors Json? // { vibrant, darkVibrant, lightVibrant, muted, darkMuted, lightMuted }
}
```
## API Changes
### Upload Endpoint (`/api/media/upload`)
Update the upload handler to extract colors:
```typescript
// After successful upload to Cloudinary
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
const buffer = await file.arrayBuffer()
// Extract EXIF data (existing)
const exifData = await extractExifData(file)
// Extract dominant colors (new)
const colorData = await extractDominantColors(buffer)
// Combine data
const metadata = {
...exifData,
colors: colorData
}
}
```
### Response Format
```json
{
"id": 123,
"url": "...",
"dominantColors": {
"vibrant": "#4285f4",
"darkVibrant": "#1a73e8",
"lightVibrant": "#8ab4f8",
"muted": "#5f6368",
"darkMuted": "#3c4043",
"lightMuted": "#e8eaed"
}
}
```
## UI/UX Considerations
### 1. Media Library Display
- Show color swatches on hover/focus
- Optional: Color-based filtering or sorting
### 2. Gallery Image Modal
- Display color palette in metadata section
- Show hex values for each color
- Copy-to-clipboard functionality for colors
### 3. Album/Gallery Views
- Use dominant color for background accents
- Create dynamic gradients from extracted colors
- Enhance loading states with color placeholders
### 4. Potential Future Features
- Color-based search ("find blue images")
- Automatic theme generation for albums
- Color harmony analysis for galleries
## Implementation Plan
### Phase 1: Backend Implementation (1 day)
1. Install and configure node-vibrant
2. Create color extraction utility function
3. Integrate into upload pipeline
4. Update database schema (migration)
5. Update API responses
### Phase 2: Basic Frontend Display (0.5 day)
1. Update Media type definitions
2. Display colors in GalleryImageModal
3. Add color swatches to media details
### Phase 3: Enhanced UI Features (1 day)
1. Implement color-based backgrounds
2. Add loading placeholders with colors
3. Create color palette component
### Phase 4: Testing & Optimization (0.5 day)
1. Test with various image types
2. Optimize for performance
3. Handle edge cases (B&W images, etc.)
## Success Metrics
1. **Performance**: Color extraction adds < 200ms to upload time
2. **Accuracy**: Colors accurately represent image content
3. **Coverage**: 95%+ of uploaded images have color data
4. **User Experience**: Improved visual coherence in galleries
## Edge Cases & Considerations
1. **Black & White Images**: Should return grayscale values
2. **Transparent PNGs**: Handle alpha channel appropriately
3. **Very Large Images**: Consider downsampling for performance
4. **Failed Extraction**: Gracefully handle errors without blocking upload
## Future Enhancements
1. **Color Search**: Search images by dominant color
2. **Auto-Tagging**: Suggest tags based on color analysis
3. **Accessibility**: Use colors to improve contrast warnings
4. **Analytics**: Track most common colors in library
5. **Batch Processing**: Extract colors for existing images
## Dependencies
- `node-vibrant`: ^3.2.1
- No additional infrastructure required
- Compatible with existing Cloudinary workflow
## Timeline
- Total effort: 2-3 days
- Can be implemented incrementally
- No breaking changes to existing functionality

View file

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "mediaId" INTEGER;
-- CreateIndex
CREATE INDEX "Photo_mediaId_idx" ON "Photo"("mediaId");
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -86,6 +86,7 @@ model Album {
model Photo {
id Int @id @default(autoincrement())
albumId Int?
mediaId Int? // Reference to the Media item
filename String @db.VarChar(255)
url String @db.VarChar(500)
thumbnailUrl String? @db.VarChar(500)
@ -107,9 +108,11 @@ model Photo {
// Relations
album Album? @relation(fields: [albumId], references: [id], onDelete: Cascade)
media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
@@index([slug])
@@index([status])
@@index([mediaId])
}
// Media table (general uploads)
@ -133,6 +136,7 @@ model Media {
// Relations
usage MediaUsage[]
photos Photo[]
}
// Media usage tracking table

View file

@ -0,0 +1,113 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function checkPhotosDisplay() {
try {
console.log('=== Checking Photos Display ===\n')
// Check albums marked for photography
const photographyAlbums = await prisma.album.findMany({
where: {
status: 'published',
isPhotography: true
},
include: {
photos: {
where: {
status: 'published'
}
}
}
})
console.log(`Found ${photographyAlbums.length} published photography albums:`)
photographyAlbums.forEach(album => {
console.log(`- "${album.title}" (${album.slug}): ${album.photos.length} published photos`)
})
// Check individual photos marked to show in photos
const individualPhotos = await prisma.photo.findMany({
where: {
status: 'published',
showInPhotos: true,
albumId: null
}
})
console.log(`\nFound ${individualPhotos.length} individual photos marked to show in Photos`)
individualPhotos.forEach(photo => {
console.log(`- Photo ID ${photo.id}: ${photo.filename}`)
})
// Check if there are any published photos in albums
const photosInAlbums = await prisma.photo.findMany({
where: {
status: 'published',
showInPhotos: true,
albumId: { not: null }
},
include: {
album: true
}
})
console.log(`\nFound ${photosInAlbums.length} published photos in albums with showInPhotos=true`)
const albumGroups = photosInAlbums.reduce((acc, photo) => {
const albumTitle = photo.album?.title || 'Unknown'
acc[albumTitle] = (acc[albumTitle] || 0) + 1
return acc
}, {} as Record<string, number>)
Object.entries(albumGroups).forEach(([album, count]) => {
console.log(`- Album "${album}": ${count} photos`)
})
// Check media marked as photography
const photographyMedia = await prisma.media.findMany({
where: {
isPhotography: true
}
})
console.log(`\nFound ${photographyMedia.length} media items marked as photography`)
// Check for any photos regardless of status
const allPhotos = await prisma.photo.findMany({
include: {
album: true
}
})
console.log(`\nTotal photos in database: ${allPhotos.length}`)
const statusCounts = allPhotos.reduce((acc, photo) => {
acc[photo.status] = (acc[photo.status] || 0) + 1
return acc
}, {} as Record<string, number>)
Object.entries(statusCounts).forEach(([status, count]) => {
console.log(`- Status "${status}": ${count} photos`)
})
// Check all albums
const allAlbums = await prisma.album.findMany({
include: {
_count: {
select: { photos: true }
}
}
})
console.log(`\nTotal albums in database: ${allAlbums.length}`)
allAlbums.forEach(album => {
console.log(`- "${album.title}" (${album.slug}): status=${album.status}, isPhotography=${album.isPhotography}, photos=${album._count.photos}`)
})
} catch (error) {
console.error('Error checking photos:', error)
} finally {
await prisma.$disconnect()
}
}
checkPhotosDisplay()

42
scripts/debug-photos.md Normal file
View file

@ -0,0 +1,42 @@
# Debug Photos Display
This directory contains tools to debug why photos aren't appearing on the photos page.
## API Test Endpoint
Visit the following URL in your browser while the dev server is running:
```
http://localhost:5173/api/test-photos
```
This endpoint will return detailed information about:
- All photos with showInPhotos=true and albumId=null
- Status distribution of these photos
- Raw SQL query results
- Comparison with what the /api/photos endpoint expects
## Database Query Script
Run the following command to query the database directly:
```bash
npx tsx scripts/test-photos-query.ts
```
This script will show:
- Total photos in the database
- Photos matching the criteria (showInPhotos=true, albumId=null)
- Status distribution
- Published vs draft photos
- All unique status values in the database
## What to Check
1. **Status Values**: The main photos API expects `status='published'`. Check if your photos have this status.
2. **showInPhotos Flag**: Make sure photos have `showInPhotos=true`
3. **Album Association**: Photos should have `albumId=null` to appear as individual photos
## Common Issues
- Photos might be in 'draft' status instead of 'published'
- Photos might have showInPhotos=false
- Photos might be associated with an album (albumId is not null)

199
scripts/test-media-sharing.ts Executable file
View file

@ -0,0 +1,199 @@
#!/usr/bin/env tsx
// Test script to verify that Media can be shared across multiple albums
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function testMediaSharing() {
console.log('Testing Media sharing across albums...\n')
try {
// 1. Create a test media item
console.log('1. Creating test media item...')
const media = await prisma.media.create({
data: {
filename: 'test-shared-image.jpg',
originalName: 'Test Shared Image',
mimeType: 'image/jpeg',
size: 1024000,
url: 'https://example.com/test-shared-image.jpg',
thumbnailUrl: 'https://example.com/test-shared-image-thumb.jpg',
width: 1920,
height: 1080,
altText: 'A test image that will be shared across albums',
description: 'This is a test image to verify media sharing',
isPhotography: true
}
})
console.log(`✓ Created media with ID: ${media.id}\n`)
// 2. Create two test albums
console.log('2. Creating test albums...')
const album1 = await prisma.album.create({
data: {
slug: 'test-album-1',
title: 'Test Album 1',
description: 'First test album for media sharing',
status: 'published'
}
})
console.log(`✓ Created album 1 with ID: ${album1.id}`)
const album2 = await prisma.album.create({
data: {
slug: 'test-album-2',
title: 'Test Album 2',
description: 'Second test album for media sharing',
status: 'published'
}
})
console.log(`✓ Created album 2 with ID: ${album2.id}\n`)
// 3. Add the same media to both albums
console.log('3. Adding media to both albums...')
const photo1 = await prisma.photo.create({
data: {
albumId: album1.id,
mediaId: media.id,
filename: media.filename,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
caption: 'Same media in album 1',
displayOrder: 1,
status: 'published',
showInPhotos: true
}
})
console.log(`✓ Added photo to album 1 with ID: ${photo1.id}`)
const photo2 = await prisma.photo.create({
data: {
albumId: album2.id,
mediaId: media.id,
filename: media.filename,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
caption: 'Same media in album 2',
displayOrder: 1,
status: 'published',
showInPhotos: true
}
})
console.log(`✓ Added photo to album 2 with ID: ${photo2.id}\n`)
// 4. Create media usage records
console.log('4. Creating media usage records...')
await prisma.mediaUsage.createMany({
data: [
{
mediaId: media.id,
contentType: 'album',
contentId: album1.id,
fieldName: 'photos'
},
{
mediaId: media.id,
contentType: 'album',
contentId: album2.id,
fieldName: 'photos'
}
]
})
console.log('✓ Created media usage records\n')
// 5. Verify the media is in both albums
console.log('5. Verifying media is in both albums...')
const verifyAlbum1 = await prisma.album.findUnique({
where: { id: album1.id },
include: {
photos: {
include: {
media: true
}
}
}
})
const verifyAlbum2 = await prisma.album.findUnique({
where: { id: album2.id },
include: {
photos: {
include: {
media: true
}
}
}
})
console.log(`✓ Album 1 has ${verifyAlbum1?.photos.length} photo(s)`)
console.log(` - Photo mediaId: ${verifyAlbum1?.photos[0]?.mediaId}`)
console.log(` - Media filename: ${verifyAlbum1?.photos[0]?.media?.filename}`)
console.log(`✓ Album 2 has ${verifyAlbum2?.photos.length} photo(s)`)
console.log(` - Photo mediaId: ${verifyAlbum2?.photos[0]?.mediaId}`)
console.log(` - Media filename: ${verifyAlbum2?.photos[0]?.media?.filename}\n`)
// 6. Check media usage
console.log('6. Checking media usage records...')
const mediaUsage = await prisma.mediaUsage.findMany({
where: { mediaId: media.id }
})
console.log(`✓ Media is used in ${mediaUsage.length} places:`)
mediaUsage.forEach((usage) => {
console.log(` - ${usage.contentType} ID ${usage.contentId} (${usage.fieldName})`)
})
// 7. Verify media can be queried with all its photos
console.log('\n7. Querying media with all photos...')
const mediaWithPhotos = await prisma.media.findUnique({
where: { id: media.id },
include: {
photos: {
include: {
album: true
}
}
}
})
console.log(`✓ Media is in ${mediaWithPhotos?.photos.length} photos:`)
mediaWithPhotos?.photos.forEach((photo) => {
console.log(` - Photo ID ${photo.id} in album "${photo.album?.title}"`)
})
console.log('\n✅ SUCCESS: Media can be shared across multiple albums!')
// Cleanup
console.log('\n8. Cleaning up test data...')
await prisma.mediaUsage.deleteMany({
where: { mediaId: media.id }
})
await prisma.photo.deleteMany({
where: { mediaId: media.id }
})
await prisma.album.deleteMany({
where: {
id: {
in: [album1.id, album2.id]
}
}
})
await prisma.media.delete({
where: { id: media.id }
})
console.log('✓ Test data cleaned up')
} catch (error) {
console.error('\n❌ ERROR:', error)
process.exit(1)
} finally {
await prisma.$disconnect()
}
}
// Run the test
testMediaSharing()

View file

@ -0,0 +1,106 @@
import { PrismaClient } from '@prisma/client'
import 'dotenv/config'
const prisma = new PrismaClient()
async function testPhotoQueries() {
console.log('=== Testing Photo Queries ===\n')
try {
// Query 1: Count all photos
const totalPhotos = await prisma.photo.count()
console.log(`Total photos in database: ${totalPhotos}`)
// Query 2: Photos with showInPhotos=true and albumId=null
const photosForDisplay = await prisma.photo.findMany({
where: {
showInPhotos: true,
albumId: null
},
select: {
id: true,
slug: true,
filename: true,
status: true,
showInPhotos: true,
albumId: true,
publishedAt: true,
createdAt: true
}
})
console.log(`\nPhotos with showInPhotos=true and albumId=null: ${photosForDisplay.length}`)
photosForDisplay.forEach(photo => {
console.log(` - ID: ${photo.id}, Status: ${photo.status}, Slug: ${photo.slug || 'none'}, File: ${photo.filename}`)
})
// Query 3: Check status distribution
const statusCounts = await prisma.photo.groupBy({
by: ['status'],
where: {
showInPhotos: true,
albumId: null
},
_count: {
id: true
}
})
console.log('\nStatus distribution for photos with showInPhotos=true and albumId=null:')
statusCounts.forEach(({ status, _count }) => {
console.log(` - ${status}: ${_count.id}`)
})
// Query 4: Published photos that should appear
const publishedPhotos = await prisma.photo.findMany({
where: {
status: 'published',
showInPhotos: true,
albumId: null
}
})
console.log(`\nPublished photos (status='published', showInPhotos=true, albumId=null): ${publishedPhotos.length}`)
publishedPhotos.forEach(photo => {
console.log(` - ID: ${photo.id}, File: ${photo.filename}, Published: ${photo.publishedAt}`)
})
// Query 5: Check if there are any draft photos that might need publishing
const draftPhotos = await prisma.photo.findMany({
where: {
status: 'draft',
showInPhotos: true,
albumId: null
}
})
if (draftPhotos.length > 0) {
console.log(`\n⚠ Found ${draftPhotos.length} draft photos with showInPhotos=true:`)
draftPhotos.forEach(photo => {
console.log(` - ID: ${photo.id}, File: ${photo.filename}`)
})
console.log('These photos need to be published to appear in the photos page!')
}
// Query 6: Check unique statuses in the database
const uniqueStatuses = await prisma.photo.findMany({
distinct: ['status'],
select: {
status: true
}
})
console.log('\nAll unique status values in the database:')
uniqueStatuses.forEach(({ status }) => {
console.log(` - "${status}"`)
})
} catch (error) {
console.error('Error running queries:', error)
} finally {
await prisma.$disconnect()
}
}
// Run the test
testPhotoQueries()

View file

@ -10,25 +10,40 @@ export function setAdminAuth(username: string, password: string) {
// Get auth headers for API requests
export function getAuthHeaders(): HeadersInit {
if (!adminCredentials) {
// For development, use default credentials
// In production, this should redirect to login
adminCredentials = btoa('admin:localdev')
// First try to get from localStorage (where login stores it)
const storedAuth = typeof window !== 'undefined' ? localStorage.getItem('admin_auth') : null
if (storedAuth) {
return {
Authorization: `Basic ${storedAuth}`
}
}
// Fall back to in-memory credentials if set
if (adminCredentials) {
return {
Authorization: `Basic ${adminCredentials}`
}
}
// Development fallback
const fallbackAuth = btoa('admin:localdev')
return {
Authorization: `Basic ${adminCredentials}`
Authorization: `Basic ${fallbackAuth}`
}
}
// Check if user is authenticated (basic check)
export function isAuthenticated(): boolean {
return adminCredentials !== null
const storedAuth = typeof window !== 'undefined' ? localStorage.getItem('admin_auth') : null
return storedAuth !== null || adminCredentials !== null
}
// Clear auth (logout)
export function clearAuth() {
adminCredentials = null
if (typeof window !== 'undefined') {
localStorage.removeItem('admin_auth')
}
}
// Make authenticated API request

View file

@ -24,8 +24,9 @@
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
goto(`/photos/${albumSlug}/${photoId}`)
} else {
// For standalone photos, navigate to a generic photo page (to be implemented)
console.log('Individual photo navigation not yet implemented')
// Navigate to individual photo page using the photo ID
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
goto(`/photos/p/${photoId}`)
}
}
}

View file

@ -4,6 +4,7 @@
import Input from './Input.svelte'
import SmartImage from '../SmartImage.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import MediaDetailsModal from './MediaDetailsModal.svelte'
import { authenticatedFetch } from '$lib/admin-auth'
interface Props {
@ -20,6 +21,7 @@
helpText?: string
showBrowseLibrary?: boolean
maxFileSize?: number // MB limit
disabled?: boolean
}
let {
@ -35,7 +37,8 @@
placeholder = 'Drag and drop images here, or click to browse',
helpText,
showBrowseLibrary = false,
maxFileSize = 10
maxFileSize = 10,
disabled = false
}: Props = $props()
// State
@ -47,6 +50,8 @@
let draggedIndex = $state<number | null>(null)
let draggedOverIndex = $state<number | null>(null)
let isMediaLibraryOpen = $state(false)
let isImageModalOpen = $state(false)
let selectedImage = $state<any | null>(null)
// Computed properties
const hasImages = $derived(value && value.length > 0)
@ -93,7 +98,7 @@
// Handle file selection/drop
async function handleFiles(files: FileList) {
if (files.length === 0) return
if (files.length === 0 || disabled) return
// Validate files
const filesToUpload: File[] = []
@ -150,8 +155,13 @@
// Brief delay to show completion
setTimeout(() => {
const newValue = [...(value || []), ...uploadedMedia]
value = newValue
console.log('[GalleryUploader] Upload completed:', {
uploadedCount: uploadedMedia.length,
uploaded: uploadedMedia.map(m => ({ id: m.id, filename: m.filename })),
currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
})
// Don't update value here - let parent handle it through API response
// Only pass the newly uploaded media, not the entire gallery
onUpload(uploadedMedia)
isUploading = false
@ -214,53 +224,26 @@
uploadError = null
}
// Update alt text on server
async function handleAltTextChange(item: any, newAltText: string) {
if (!item) return
try {
// For album photos, use mediaId; for direct media objects, use id
const mediaId = item.mediaId || item.id
if (!mediaId) {
console.error('No media ID found for alt text update')
return
}
const response = await authenticatedFetch(`/api/media/${mediaId}/metadata`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
altText: newAltText.trim() || null
})
})
if (response.ok) {
const updatedData = await response.json()
if (value) {
const index = value.findIndex((v) => (v.mediaId || v.id) === mediaId)
if (index !== -1) {
value[index] = {
...value[index],
altText: updatedData.altText,
updatedAt: updatedData.updatedAt
}
value = [...value]
}
}
}
} catch (error) {
console.error('Failed to update alt text:', error)
}
}
// Drag and drop reordering handlers
function handleImageDragStart(event: DragEvent, index: number) {
// Prevent reordering while uploading or disabled
if (isUploading || disabled) {
event.preventDefault()
return
}
draggedIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
// Debug logging
console.log('[GalleryUploader] Drag start:', {
index,
item: value[index],
totalItems: value.length
})
}
function handleImageDragOver(event: DragEvent, index: number) {
@ -278,7 +261,20 @@
function handleImageDrop(event: DragEvent, dropIndex: number) {
event.preventDefault()
if (draggedIndex === null || !value) return
if (draggedIndex === null || !value || isUploading || disabled) return
// Debug logging before reorder
console.log('[GalleryUploader] Before reorder:', {
draggedIndex,
dropIndex,
totalItems: value.length,
items: value.map((v, i) => ({
index: i,
id: v.id,
mediaId: v.mediaId,
filename: v.filename
}))
})
const newValue = [...value]
const draggedItem = newValue[draggedIndex]
@ -290,6 +286,17 @@
const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
newValue.splice(adjustedDropIndex, 0, draggedItem)
// Debug logging after reorder
console.log('[GalleryUploader] After reorder:', {
adjustedDropIndex,
newItems: newValue.map((v, i) => ({
index: i,
id: v.id,
mediaId: v.mediaId,
filename: v.filename
}))
})
value = newValue
onUpload(newValue)
if (onReorder) {
@ -314,6 +321,13 @@
// For gallery mode, selectedMedia will be an array
const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia]
// Debug logging
console.log('[GalleryUploader] Media selected from library:', {
selectedCount: mediaArray.length,
selected: mediaArray.map(m => ({ id: m.id, filename: m.filename })),
currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
})
// Filter out duplicates before passing to parent
// Create a comprehensive set of existing IDs (both id and mediaId)
const existingIds = new Set()
@ -327,6 +341,11 @@
return !existingIds.has(media.id) && !existingIds.has(media.mediaId)
})
console.log('[GalleryUploader] Filtered new media:', {
newCount: newMedia.length,
newMedia: newMedia.map(m => ({ id: m.id, filename: m.filename }))
})
if (newMedia.length > 0) {
// Don't modify the value array here - let the parent component handle it
// through the API calls and then update the bound value
@ -337,6 +356,49 @@
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
// Handle clicking on an image to open details modal
function handleImageClick(media: any) {
// Convert to Media format if needed
selectedImage = {
id: media.mediaId || media.id,
filename: media.filename,
originalName: media.originalName || media.filename,
mimeType: media.mimeType || 'image/jpeg',
size: media.size || 0,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
altText: media.altText || '',
description: media.description || '',
isPhotography: media.isPhotography || false,
createdAt: media.createdAt,
updatedAt: media.updatedAt,
exifData: media.exifData || null,
usedIn: media.usedIn || []
}
isImageModalOpen = true
}
// Handle updates from the media details modal
function handleImageUpdate(updatedMedia: any) {
// Update the media in our value array
const index = value.findIndex(m => (m.mediaId || m.id) === updatedMedia.id)
if (index !== -1) {
value[index] = {
...value[index],
altText: updatedMedia.altText,
description: updatedMedia.description,
isPhotography: updatedMedia.isPhotography,
updatedAt: updatedMedia.updatedAt
}
value = [...value] // Trigger reactivity
}
// Update selectedImage for the modal
selectedImage = updatedMedia
}
</script>
<div class="gallery-uploader">
@ -347,10 +409,11 @@
class:drag-over={isDragOver}
class:uploading={isUploading}
class:has-error={!!uploadError}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={handleBrowseClick}
class:disabled={disabled}
ondragover={disabled ? undefined : handleDragOver}
ondragleave={disabled ? undefined : handleDragLeave}
ondrop={disabled ? undefined : handleDrop}
onclick={disabled ? undefined : handleBrowseClick}
>
{#if isUploading}
<!-- Upload Progress -->
@ -461,12 +524,12 @@
<!-- Action Buttons -->
{#if !isUploading && canAddMore}
<div class="action-buttons">
<Button variant="primary" onclick={handleBrowseClick}>
<Button variant="primary" onclick={handleBrowseClick} disabled={disabled}>
{hasImages ? 'Add More Images' : 'Choose Images'}
</Button>
{#if showBrowseLibrary}
<Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
<Button variant="ghost" onclick={handleBrowseLibrary} disabled={disabled}>Browse Library</Button>
{/if}
</div>
{/if}
@ -474,12 +537,13 @@
<!-- Image Gallery -->
{#if hasImages}
<div class="image-gallery">
{#each value as media, index (`${media.mediaId || media.id || index}`)}
{#each value as media, index (`photo-${media.id || 'temp'}-${media.mediaId || 'new'}-${index}`)}
<div
class="gallery-item"
class:dragging={draggedIndex === index}
class:drag-over={draggedOverIndex === index}
draggable="true"
class:disabled={disabled}
draggable={!disabled}
ondragstart={(e) => handleImageDragStart(e, index)}
ondragover={(e) => handleImageDragOver(e, index)}
ondragleave={handleImageDragLeave}
@ -506,36 +570,48 @@
<!-- Image Preview -->
<div class="image-preview">
<SmartImage
media={{
id: media.mediaId || media.id,
filename: media.filename,
originalName: media.originalName || media.filename,
mimeType: media.mimeType || 'image/jpeg',
size: media.size || 0,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
altText: media.altText,
description: media.description,
isPhotography: media.isPhotography || false,
createdAt: media.createdAt,
updatedAt: media.updatedAt
}}
alt={media.altText || media.filename || 'Gallery image'}
containerWidth={300}
loading="lazy"
aspectRatio="1:1"
class="gallery-image"
/>
<button
class="image-button"
type="button"
onclick={() => handleImageClick(media)}
aria-label="Edit image {media.filename}"
disabled={disabled}
>
<SmartImage
media={{
id: media.mediaId || media.id,
filename: media.filename,
originalName: media.originalName || media.filename,
mimeType: media.mimeType || 'image/jpeg',
size: media.size || 0,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
altText: media.altText,
description: media.description,
isPhotography: media.isPhotography || false,
createdAt: media.createdAt,
updatedAt: media.updatedAt
}}
alt={media.altText || media.filename || 'Gallery image'}
containerWidth={300}
loading="lazy"
aspectRatio="1:1"
class="gallery-image"
/>
</button>
<!-- Remove Button -->
<button
class="remove-button"
onclick={() => handleRemoveImage(index)}
onclick={(e) => {
e.stopPropagation()
handleRemoveImage(index)
}}
type="button"
aria-label="Remove image"
disabled={disabled}
>
<svg
width="16"
@ -568,20 +644,6 @@
</button>
</div>
<!-- Alt Text Input -->
{#if allowAltText}
<div class="alt-text-input">
<Input
type="text"
label="Alt Text"
value={media.altText || ''}
placeholder="Describe this image"
buttonSize="small"
onblur={(e) => handleAltTextChange(media, e.target.value)}
/>
</div>
{/if}
<!-- File Info -->
<div class="file-info">
<p class="filename">{media.originalName || media.filename}</p>
@ -624,6 +686,17 @@
onClose={handleMediaLibraryClose}
/>
<!-- Media Details Modal -->
<MediaDetailsModal
bind:isOpen={isImageModalOpen}
media={selectedImage}
onClose={() => {
isImageModalOpen = false
selectedImage = null
}}
onUpdate={handleImageUpdate}
/>
<style lang="scss">
.gallery-uploader {
display: flex;
@ -683,6 +756,16 @@
border-color: $red-60;
background-color: rgba($red-60, 0.02);
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
&:hover {
border-color: $grey-80;
background-color: $grey-97;
}
}
}
.upload-prompt {
@ -828,18 +911,50 @@
&:hover .drag-handle {
opacity: 1;
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
.drag-handle {
cursor: not-allowed;
}
&:hover .drag-handle {
opacity: 0;
}
}
}
.image-preview {
position: relative;
aspect-ratio: 1;
overflow: hidden;
background-color: $grey-97;
:global(.gallery-image) {
.image-button {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
padding: 0;
border: none;
background: none;
cursor: pointer;
transition: transform 0.2s ease;
&:hover:not(:disabled) {
transform: scale(1.02);
}
&:disabled {
cursor: not-allowed;
}
:global(.gallery-image) {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.remove-button {
@ -858,12 +973,17 @@
color: $grey-40;
opacity: 0;
transition: all 0.2s ease;
z-index: 1;
&:hover {
background: white;
color: $red-60;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&:disabled {
cursor: not-allowed;
}
}
&:hover .remove-button {
@ -871,9 +991,6 @@
}
}
.alt-text-input {
padding: $unit-2x;
}
.file-info {
padding: $unit-2x;

View file

@ -2,6 +2,7 @@
import Modal from './Modal.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import Textarea from './Textarea.svelte'
import SmartImage from '../SmartImage.svelte'
import { authenticatedFetch } from '$lib/admin-auth'
import type { Media } from '@prisma/client'
@ -36,6 +37,9 @@
>([])
let loadingUsage = $state(false)
// EXIF toggle state
let showExif = $state(false)
// Initialize form when media changes
$effect(() => {
if (media) {
@ -44,6 +48,7 @@
isPhotography = media.isPhotography || false
error = ''
successMessage = ''
showExif = false
loadUsage()
}
})
@ -190,129 +195,197 @@
{#if media}
<Modal
bind:isOpen
size="large"
size="jumbo"
closeOnBackdrop={!isSaving}
closeOnEscape={!isSaving}
on:close={handleClose}
showCloseButton={false}
>
<div class="media-details-modal">
<!-- Header -->
<div class="modal-header">
<div class="header-content">
<h2>Media Details</h2>
<p class="filename">{media.filename}</p>
</div>
{#if !isSaving}
<Button variant="ghost" onclick={handleClose} iconOnly aria-label="Close modal">
<!-- Left Pane - Image Preview -->
<div class="image-pane">
{#if media.mimeType.startsWith('image/')}
<div class="image-container">
<SmartImage {media} alt={media.altText || media.filename} class="preview-image" />
</div>
{:else}
<div class="file-placeholder">
<svg
slot="icon"
width="24"
height="24"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
<span class="file-type">{getFileType(media.mimeType)}</span>
</div>
{/if}
</div>
<!-- Content -->
<div class="modal-body">
<div class="media-preview-section">
<!-- Media Preview -->
<div class="media-preview">
{#if media.mimeType.startsWith('image/')}
<SmartImage {media} alt={media.altText || media.filename} />
{:else}
<div class="file-placeholder">
<!-- Right Pane - Details -->
<div class="details-pane">
<!-- Header -->
<div class="pane-header">
<h2 class="filename-header">{media.filename}</h2>
<div class="header-actions">
{#if !isSaving}
<Button variant="ghost" onclick={copyUrl} iconOnly aria-label="Copy URL">
<svg
width="64"
height="64"
slot="icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M5 15H4C2.89543 15 2 14.1046 2 13V4C2 2.89543 2.89543 2 4 2H13C14.1046 2 15 2.89543 15 4V5"
stroke="currentColor"
stroke-width="2"
/>
</svg>
</Button>
<Button variant="ghost" onclick={handleClose} iconOnly aria-label="Close modal">
<svg
slot="icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="file-type">{getFileType(media.mimeType)}</span>
</div>
</Button>
{/if}
</div>
<!-- File Info -->
<div class="file-info">
<div class="info-row">
<span class="label">Type:</span>
<span class="value">{getFileType(media.mimeType)}</span>
</div>
<div class="info-row">
<span class="label">Size:</span>
<span class="value">{formatFileSize(media.size)}</span>
</div>
{#if media.width && media.height}
<div class="info-row">
<span class="label">Dimensions:</span>
<span class="value">{media.width} × {media.height}px</span>
</div>
{/if}
<div class="info-row">
<span class="label">Uploaded:</span>
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
</div>
<div class="info-row">
<span class="label">URL:</span>
<div class="url-section">
<span class="url-text">{media.url}</span>
<Button variant="ghost" buttonSize="small" onclick={copyUrl}>Copy</Button>
</div>
</div>
</div>
</div>
<!-- Edit Form -->
<div class="edit-form">
<h3>Accessibility & SEO</h3>
<!-- Content -->
<div class="pane-body">
<!-- File Info -->
<div class="file-info">
<div class="info-grid">
<div class="info-item">
<span class="label">Type</span>
<span class="value">{getFileType(media.mimeType)}</span>
</div>
<div class="info-item">
<span class="label">Size</span>
<span class="value">{formatFileSize(media.size)}</span>
</div>
{#if media.width && media.height}
<div class="info-item">
<span class="label">Dimensions</span>
<span class="value">{media.width} × {media.height}px</span>
</div>
{/if}
<div class="info-item">
<span class="label">Uploaded</span>
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
</div>
</div>
<Input
type="text"
label="Alt Text"
bind:value={altText}
placeholder="Describe this image for screen readers"
helpText="Help make your content accessible. Describe what's in the image."
disabled={isSaving}
fullWidth
/>
{#if media.exifData && Object.keys(media.exifData).length > 0}
{#if showExif}
<div class="exif-data">
{#if media.exifData.camera}
<div class="info-item">
<span class="label">Camera</span>
<span class="value">{media.exifData.camera}</span>
</div>
{/if}
{#if media.exifData.lens}
<div class="info-item">
<span class="label">Lens</span>
<span class="value">{media.exifData.lens}</span>
</div>
{/if}
{#if media.exifData.focalLength}
<div class="info-item">
<span class="label">Focal Length</span>
<span class="value">{media.exifData.focalLength}</span>
</div>
{/if}
{#if media.exifData.aperture}
<div class="info-item">
<span class="label">Aperture</span>
<span class="value">{media.exifData.aperture}</span>
</div>
{/if}
{#if media.exifData.shutterSpeed}
<div class="info-item">
<span class="label">Shutter Speed</span>
<span class="value">{media.exifData.shutterSpeed}</span>
</div>
{/if}
{#if media.exifData.iso}
<div class="info-item">
<span class="label">ISO</span>
<span class="value">{media.exifData.iso}</span>
</div>
{/if}
{#if media.exifData.dateTaken}
<div class="info-item">
<span class="label">Date Taken</span>
<span class="value"
>{new Date(media.exifData.dateTaken).toLocaleDateString()}</span
>
</div>
{/if}
{#if media.exifData.coordinates}
<div class="info-item">
<span class="label">GPS</span>
<span class="value">
{media.exifData.coordinates.latitude.toFixed(6)},
{media.exifData.coordinates.longitude.toFixed(6)}
</span>
</div>
{/if}
</div>
{/if}
<Input
type="textarea"
label="Description (Optional)"
bind:value={description}
placeholder="Additional description or caption"
helpText="Optional longer description for context or captions."
rows={3}
disabled={isSaving}
fullWidth
/>
<Button
variant="ghost"
onclick={() => (showExif = !showExif)}
buttonSize="small"
fullWidth
pill={false}
class="exif-toggle"
>
{showExif ? 'Hide EXIF' : 'Show EXIF'}
</Button>
{/if}
</div>
<!-- Photography Toggle -->
<div class="photography-toggle">
@ -323,80 +396,104 @@
disabled={isSaving}
class="toggle-input"
/>
<span class="toggle-slider"></span>
<div class="toggle-content">
<span class="toggle-title">Photography</span>
<span class="toggle-description">Show this media in the photography experience</span
>
<span class="toggle-title">Show in Photos</span>
<span class="toggle-description">This photo will be displayed in Photos</span>
</div>
<span class="toggle-slider"></span>
</label>
</div>
<!-- Usage Tracking -->
<div class="usage-section">
<h4>Used In</h4>
{#if loadingUsage}
<div class="usage-loading">
<div class="spinner"></div>
<span>Loading usage information...</span>
</div>
{:else if usage.length > 0}
<ul class="usage-list">
{#each usage as usageItem}
<li class="usage-item">
<div class="usage-content">
<div class="usage-header">
{#if usageItem.contentUrl}
<a
href={usageItem.contentUrl}
class="usage-title"
target="_blank"
rel="noopener"
<!-- Edit Form -->
<div class="edit-form">
<Textarea
label="Alt Text"
bind:value={altText}
placeholder="Describe this image for screen readers"
rows={3}
disabled={isSaving}
fullWidth
/>
<Textarea
label="Description"
bind:value={description}
placeholder="Additional description or caption"
rows={3}
disabled={isSaving}
fullWidth
/>
<!-- Usage Tracking -->
<div class="usage-section">
<h4>Used In</h4>
{#if loadingUsage}
<div class="usage-loading">
<div class="spinner"></div>
<span>Loading usage information...</span>
</div>
{:else if usage.length > 0}
<ul class="usage-list">
{#each usage as usageItem}
<li class="usage-item">
<div class="usage-content">
<div class="usage-header">
{#if usageItem.contentUrl}
<a
href={usageItem.contentUrl}
class="usage-title"
target="_blank"
rel="noopener"
>
{usageItem.contentTitle}
</a>
{:else}
<span class="usage-title">{usageItem.contentTitle}</span>
{/if}
<span class="usage-type">{usageItem.contentType}</span>
</div>
<div class="usage-details">
<span class="usage-field">{usageItem.fieldDisplayName}</span>
<span class="usage-date"
>Added {new Date(usageItem.createdAt).toLocaleDateString()}</span
>
{usageItem.contentTitle}
</a>
{:else}
<span class="usage-title">{usageItem.contentTitle}</span>
{/if}
<span class="usage-type">{usageItem.contentType}</span>
</div>
</div>
<div class="usage-details">
<span class="usage-field">{usageItem.fieldDisplayName}</span>
<span class="usage-date"
>Added {new Date(usageItem.createdAt).toLocaleDateString()}</span
>
</div>
</div>
</li>
{/each}
</ul>
{:else}
<p class="no-usage">This media file is not currently used in any content.</p>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="no-usage">This media file is not currently used in any content.</p>
{/if}
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-footer">
<div class="footer-left">
<Button variant="ghost" onclick={handleDelete} disabled={isSaving} class="delete-button">
Delete
</Button>
</div>
<!-- Footer -->
<div class="pane-footer">
<div class="footer-left">
<Button
variant="ghost"
onclick={handleDelete}
disabled={isSaving}
class="delete-button"
>
Delete
</Button>
</div>
<div class="footer-right">
{#if error}
<span class="error-text">{error}</span>
{/if}
{#if successMessage}
<span class="success-text">{successMessage}</span>
{/if}
<div class="footer-right">
{#if error}
<span class="error-text">{error}</span>
{/if}
{#if successMessage}
<span class="success-text">{successMessage}</span>
{/if}
<Button variant="ghost" onclick={handleClose} disabled={isSaving}>Cancel</Button>
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</div>
</div>
@ -406,74 +503,36 @@
<style lang="scss">
.media-details-modal {
display: flex;
flex-direction: column;
height: 100%;
max-height: 90vh;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $unit-4x;
border-bottom: 1px solid $grey-90;
flex-shrink: 0;
.header-content {
flex: 1;
h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 $unit-half 0;
color: $grey-10;
}
.filename {
font-size: 0.875rem;
color: $grey-40;
margin: 0;
word-break: break-all;
}
}
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: $unit-4x;
display: flex;
flex-direction: column;
gap: $unit-6x;
}
.media-preview-section {
display: grid;
grid-template-columns: 300px 1fr;
gap: $unit-4x;
align-items: start;
@include breakpoint('tablet') {
grid-template-columns: 1fr;
gap: $unit-3x;
}
}
.media-preview {
width: 100%;
max-width: 300px;
aspect-ratio: 4/3;
border-radius: 12px;
overflow: hidden;
background: $grey-95;
}
// Left pane - Image preview
.image-pane {
flex: 1;
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
padding: $unit-4x;
position: relative;
overflow: hidden;
:global(img) {
width: 100%;
height: 100%;
object-fit: cover;
.image-container {
max-width: 90%;
max-height: 90%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
:global(.preview-image) {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: $corner-radius-md;
display: block;
}
}
.file-placeholder {
@ -481,7 +540,7 @@
flex-direction: column;
align-items: center;
gap: $unit-2x;
color: $grey-50;
color: rgba(255, 255, 255, 0.6);
.file-type {
font-size: 0.875rem;
@ -490,55 +549,114 @@
}
}
// Right pane - Details
.details-pane {
width: 400px;
background-color: white;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $unit-2x $unit-3x;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
flex-shrink: 0;
gap: $unit-2x;
.filename-header {
flex: 1;
font-size: 1.125rem;
font-weight: 500;
margin: 0;
color: $grey-10;
word-break: break-all;
line-height: 1.5;
}
.header-actions {
display: flex;
align-items: center;
gap: $unit;
}
}
.pane-body {
flex: 1;
overflow-y: auto;
padding: $unit-4x;
display: flex;
flex-direction: column;
gap: $unit-6x;
}
.file-info {
display: flex;
flex-direction: column;
gap: $unit-2x;
gap: $unit-3x;
padding: $unit-3x;
background-color: $grey-97;
border-radius: $corner-radius;
}
.info-row {
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $unit-3x;
}
.info-item {
display: flex;
align-items: center;
gap: $unit-2x;
flex-direction: column;
gap: $unit-half;
&.vertical {
grid-column: 1 / -1;
}
.label {
font-size: 0.75rem;
font-weight: 500;
color: $grey-30;
min-width: 80px;
color: $grey-50;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.value {
font-size: 0.875rem;
color: $grey-10;
flex: 1;
font-weight: 500;
}
}
.url-section {
display: flex;
align-items: center;
gap: $unit-2x;
flex: 1;
:global(.btn.btn-ghost.exif-toggle) {
margin-top: $unit-2x;
justify-content: center;
background: transparent;
border: 1px solid $grey-70;
.url-text {
color: $grey-10;
font-size: 0.875rem;
word-break: break-all;
flex: 1;
}
&:hover {
background: rgba(0, 0, 0, 0.02);
border-color: $grey-70;
}
}
.exif-data {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $unit-3x;
padding-top: $unit-3x;
border-top: 1px solid $grey-90;
}
.edit-form {
display: flex;
flex-direction: column;
gap: $unit-4x;
h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: $grey-10;
}
h4 {
font-size: 1rem;
font-weight: 600;
@ -551,6 +669,7 @@
.toggle-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-3x;
cursor: pointer;
user-select: none;
@ -561,7 +680,7 @@
opacity: 0;
pointer-events: none;
&:checked + .toggle-slider {
&:checked + .toggle-content + .toggle-slider {
background-color: $blue-60;
&::before {
@ -569,7 +688,7 @@
}
}
&:disabled + .toggle-slider {
&:disabled + .toggle-content + .toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
@ -711,12 +830,12 @@
}
}
.modal-footer {
.pane-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: $unit-4x;
border-top: 1px solid $grey-90;
padding: $unit-2x $unit-3x;
border-top: 1px solid rgba(0, 0, 0, 0.08);
flex-shrink: 0;
.footer-left {
@ -756,16 +875,30 @@
}
// Responsive adjustments
@include breakpoint('phone') {
.modal-header {
@media (max-width: 768px) {
.media-details-modal {
flex-direction: column;
}
.image-pane {
height: 300px;
flex: none;
}
.details-pane {
width: 100%;
flex: 1;
}
.pane-header {
padding: $unit-3x;
}
.modal-body {
.pane-body {
padding: $unit-3x;
}
.modal-footer {
.pane-footer {
padding: $unit-3x;
flex-direction: column;
gap: $unit-3x;

View file

@ -4,7 +4,7 @@
import Button from './Button.svelte'
export let isOpen = false
export let size: 'small' | 'medium' | 'large' | 'full' = 'medium'
export let size: 'small' | 'medium' | 'large' | 'jumbo' | 'full' = 'medium'
export let closeOnBackdrop = true
export let closeOnEscape = true
export let showCloseButton = true
@ -118,6 +118,12 @@
max-width: 800px;
}
&.modal-jumbo {
width: 90vw;
max-width: 1400px;
height: 80vh;
}
&.modal-full {
width: 100%;
max-width: 1200px;

View file

@ -17,6 +17,7 @@ export interface Photo {
width: number
height: number
exif?: ExifData
createdAt?: string
}
export interface PhotoAlbum {

View file

@ -180,9 +180,13 @@
if (response.ok) {
await loadAlbums()
} else {
const errorData = await response.json()
error = errorData.error || 'Failed to delete album'
}
} catch (err) {
console.error('Failed to delete album:', err)
error = 'Failed to delete album. Please try again.'
} finally {
showDeleteModal = false
albumToDelete = null
@ -264,7 +268,7 @@
bind:isOpen={showDeleteModal}
title="Delete album?"
message={albumToDelete
? `Are you sure you want to delete "${albumToDelete.title}"? This action cannot be undone.`
? `Are you sure you want to delete "${albumToDelete.title}"? The album will be deleted but all photos will remain in your media library. This action cannot be undone.`
: ''}
onConfirm={confirmDelete}
onCancel={cancelDelete}

View file

@ -12,6 +12,7 @@
import GalleryUploader from '$lib/components/admin/GalleryUploader.svelte'
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
import AlbumMetadataPopover from '$lib/components/admin/AlbumMetadataPopover.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
// Form state
let album = $state<any>(null)
@ -28,6 +29,7 @@
let isLoading = $state(true)
let isSaving = $state(false)
let error = $state('')
let showDeleteModal = $state(false)
// Photo management state
let isMediaLibraryOpen = $state(false)
@ -153,7 +155,11 @@
}
}
async function handleDelete() {
function handleDelete() {
showDeleteModal = true
}
async function confirmDelete() {
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
@ -175,9 +181,15 @@
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete album'
console.error('Failed to delete album:', err)
} finally {
showDeleteModal = false
}
}
function cancelDelete() {
showDeleteModal = false
}
function handleCancel() {
goto('/admin/albums')
}
@ -380,6 +392,24 @@
async function handlePhotoReorder(reorderedPhotos: any[]) {
try {
console.log('[Album Edit] handlePhotoReorder called:', {
reorderedCount: reorderedPhotos.length,
photos: reorderedPhotos.map((p, i) => ({
index: i,
id: p.id,
mediaId: p.mediaId,
filename: p.filename
}))
})
// Prevent concurrent reordering
if (isManagingPhotos) {
console.warn('[Album Edit] Skipping reorder - another operation in progress')
return
}
isManagingPhotos = true
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
@ -403,11 +433,17 @@
await Promise.all(updatePromises)
// Update local state
albumPhotos = reorderedPhotos
// Update local state only after successful API calls
albumPhotos = [...reorderedPhotos]
console.log('[Album Edit] Reorder completed successfully')
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to reorder photos'
console.error('Failed to reorder photos:', err)
// Revert to original order on error
albumPhotos = [...albumPhotos]
} finally {
isManagingPhotos = false
}
}
@ -457,20 +493,21 @@
// Handle new photos added through GalleryUploader (uploads or library selections)
async function handleGalleryAdd(newPhotos: any[]) {
try {
console.log('[Album Edit] handleGalleryAdd called:', {
newPhotosCount: newPhotos.length,
newPhotos: newPhotos.map(p => ({
id: p.id,
mediaId: p.mediaId,
filename: p.filename,
isFile: p instanceof File
})),
currentPhotosCount: albumPhotos.length
})
if (newPhotos.length > 0) {
// Check if these are new uploads (have File objects) or library selections (have media IDs)
const uploadsToAdd = newPhotos.filter((photo) => photo instanceof File || !photo.id)
const libraryPhotosToAdd = newPhotos.filter((photo) => photo.id && !(photo instanceof File))
// Handle new uploads
if (uploadsToAdd.length > 0) {
await handleAddPhotosFromUpload(uploadsToAdd)
}
// Handle library selections
if (libraryPhotosToAdd.length > 0) {
await handleAddPhotos(libraryPhotosToAdd)
}
// All items from GalleryUploader should be media objects, not Files
// They either come from uploads (already processed to Media) or library selections
await handleAddPhotos(newPhotos)
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to add photos'
@ -659,7 +696,8 @@
onRemove={handleGalleryRemove}
showBrowseLibrary={true}
placeholder="Add photos to this album by uploading or selecting from your media library"
helpText="Drag photos to reorder them. Click on photos to edit metadata."
helpText={isManagingPhotos ? "Processing photos..." : "Drag photos to reorder them. Click on photos to edit metadata."}
disabled={isManagingPhotos}
/>
</div>
@ -702,6 +740,17 @@
onUpdate={handleMediaUpdate}
/>
<!-- Delete Confirmation Modal -->
<DeleteConfirmationModal
bind:isOpen={showDeleteModal}
title="Delete album?"
message={album
? `Are you sure you want to delete "${album.title}"? The album will be deleted but all photos will remain in your media library. This action cannot be undone.`
: ''}
onConfirm={confirmDelete}
onCancel={cancelDelete}
/>
<style lang="scss">
@import '$styles/variables.scss';

View file

@ -20,7 +20,10 @@ export const GET: RequestHandler = async (event) => {
where: { id },
include: {
photos: {
orderBy: { displayOrder: 'asc' }
orderBy: { displayOrder: 'asc' },
include: {
media: true // Include media relation for each photo
}
},
_count: {
select: { photos: true }
@ -32,35 +35,13 @@ export const GET: RequestHandler = async (event) => {
return errorResponse('Album not found', 404)
}
// Get all media usage records for this album's photos in one query
const mediaUsages = await prisma.mediaUsage.findMany({
where: {
contentType: 'album',
contentId: album.id,
fieldName: 'photos'
},
include: {
media: true
}
})
// Create a map of media by mediaId for efficient lookup
const mediaMap = new Map()
mediaUsages.forEach((usage) => {
if (usage.media) {
mediaMap.set(usage.mediaId, usage.media)
}
})
// Enrich photos with media information using proper media usage tracking
// Enrich photos with media information from the included relation
const photosWithMedia = album.photos.map((photo) => {
// Find the corresponding media usage record for this photo
const usage = mediaUsages.find((u) => u.media && u.media.filename === photo.filename)
const media = usage?.media
const media = photo.media
return {
...photo,
mediaId: media?.id || null,
// Add media properties for backward compatibility
altText: media?.altText || '',
description: media?.description || photo.caption || '',
isPhotography: media?.isPhotography || false,
@ -184,17 +165,24 @@ export const DELETE: RequestHandler = async (event) => {
return errorResponse('Album not found', 404)
}
// Check if album has photos
if (album._count.photos > 0) {
return errorResponse('Cannot delete album that contains photos', 409)
}
// Use a transaction to ensure both operations succeed or fail together
await prisma.$transaction(async (tx) => {
// First, unlink all photos from this album (set albumId to null)
if (album._count.photos > 0) {
await tx.photo.updateMany({
where: { albumId: id },
data: { albumId: null }
})
logger.info('Unlinked photos from album', { albumId: id, photoCount: album._count.photos })
}
// Delete album
await prisma.album.delete({
where: { id }
// Then delete the album
await tx.album.delete({
where: { id }
})
})
logger.info('Album deleted', { id, slug: album.slug })
logger.info('Album deleted', { id, slug: album.slug, photosUnlinked: album._count.photos })
return new Response(null, { status: 204 })
} catch (error) {

View file

@ -63,19 +63,24 @@ export const POST: RequestHandler = async (event) => {
displayOrder = (lastPhoto?.displayOrder || 0) + 1
}
// Create photo record from media
// Create photo record linked to media
const photo = await prisma.photo.create({
data: {
albumId,
mediaId: body.mediaId, // Link to the Media record
filename: media.filename,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
exifData: media.exifData, // Include EXIF data from media
caption: media.description, // Use media description as initial caption
displayOrder,
status: 'published', // Photos in albums are published by default
showInPhotos: true
},
include: {
media: true // Include media relation in response
}
})
@ -95,18 +100,8 @@ export const POST: RequestHandler = async (event) => {
mediaId: body.mediaId
})
// Return photo with media information for frontend compatibility
const photoWithMedia = {
...photo,
mediaId: body.mediaId,
altText: media.altText,
description: media.description,
isPhotography: media.isPhotography,
mimeType: media.mimeType,
size: media.size
}
return jsonResponse(photoWithMedia)
// Return photo with full media information
return jsonResponse(photo)
} catch (error) {
logger.error('Failed to add photo to album', error as Error)
return errorResponse('Failed to add photo to album', 500)
@ -207,6 +202,9 @@ export const DELETE: RequestHandler = async (event) => {
where: {
id: photoIdNum,
albumId: albumId // Ensure photo belongs to this album
},
include: {
media: true // Include media relation to get mediaId
}
})
@ -217,22 +215,15 @@ export const DELETE: RequestHandler = async (event) => {
return errorResponse('Photo not found in this album', 404)
}
// Find and remove the specific media usage record for this photo
// We need to find the media ID associated with this photo to remove the correct usage record
const mediaUsage = await prisma.mediaUsage.findFirst({
where: {
contentType: 'album',
contentId: albumId,
fieldName: 'photos',
media: {
filename: photo.filename // Match by filename since that's how they're linked
// Remove media usage record if photo has a mediaId
if (photo.mediaId) {
await prisma.mediaUsage.deleteMany({
where: {
mediaId: photo.mediaId,
contentType: 'album',
contentId: albumId,
fieldName: 'photos'
}
}
})
if (mediaUsage) {
await prisma.mediaUsage.delete({
where: { id: mediaUsage.id }
})
}

View file

@ -29,7 +29,18 @@ export const GET: RequestHandler = async (event) => {
width: true,
height: true,
caption: true,
displayOrder: true
displayOrder: true,
mediaId: true,
media: {
select: {
id: true,
altText: true,
description: true,
isPhotography: true,
mimeType: true,
size: true
}
}
}
},
_count: {

View file

@ -57,7 +57,10 @@ export const GET: RequestHandler = async (event) => {
height: true,
caption: true,
title: true,
description: true
description: true,
createdAt: true,
publishedAt: true,
exifData: true
},
orderBy: { createdAt: 'desc' },
skip: offset,
@ -92,22 +95,38 @@ export const GET: RequestHandler = async (event) => {
}))
// Transform individual photos to Photo format
const photos: Photo[] = individualPhotos.map((photo) => ({
id: `photo-${photo.id}`,
src: photo.url,
alt: photo.title || photo.caption || photo.filename,
caption: photo.caption || undefined,
width: photo.width || 400,
height: photo.height || 400
}))
const photos: Photo[] = individualPhotos.map((photo) => {
// Extract date from EXIF data if available
let photoDate: string
if (photo.exifData && typeof photo.exifData === 'object' && 'dateTaken' in photo.exifData) {
// Use EXIF date if available
photoDate = photo.exifData.dateTaken as string
} else if (photo.publishedAt) {
// Fall back to published date
photoDate = photo.publishedAt.toISOString()
} else {
// Fall back to created date
photoDate = photo.createdAt.toISOString()
}
return {
id: `photo-${photo.id}`,
src: photo.url,
alt: photo.title || photo.caption || photo.filename,
caption: photo.caption || undefined,
width: photo.width || 400,
height: photo.height || 400,
createdAt: photoDate
}
})
// Combine albums and individual photos
const photoItems: PhotoItem[] = [...photoAlbums, ...photos]
// Sort by creation date (albums use createdAt, individual photos would need publishedAt or createdAt)
// Sort by creation date (both albums and photos now have createdAt)
photoItems.sort((a, b) => {
const dateA = 'createdAt' in a ? new Date(a.createdAt) : new Date()
const dateB = 'createdAt' in b ? new Date(b.createdAt) : new Date()
const dateA = a.createdAt ? new Date(a.createdAt) : new Date()
const dateB = b.createdAt ? new Date(b.createdAt) : new Date()
return dateB.getTime() - dateA.getTime()
})

View file

@ -16,7 +16,8 @@ export const GET: RequestHandler = async (event) => {
include: {
album: {
select: { id: true, title: true, slug: true }
}
},
media: true
}
})
@ -24,6 +25,24 @@ export const GET: RequestHandler = async (event) => {
return errorResponse('Photo not found', 404)
}
// For public access, only return published photos that are marked showInPhotos
// Admin endpoints can still access all photos
const isAdminRequest = checkAdminAuth(event)
if (!isAdminRequest) {
if (photo.status !== 'published' || !photo.showInPhotos) {
return errorResponse('Photo not found', 404)
}
// If photo is in an album, check album is published and isPhotography
if (photo.album) {
const album = await prisma.album.findUnique({
where: { id: photo.album.id }
})
if (!album || album.status !== 'published' || !album.isPhotography) {
return errorResponse('Photo not found', 404)
}
}
}
return jsonResponse(photo)
} catch (error) {
logger.error('Failed to retrieve photo', error as Error)

View file

@ -0,0 +1,241 @@
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/test-photos - Test endpoint to debug photo visibility
export const GET: RequestHandler = async () => {
try {
// Query 1: Get all photos with showInPhotos=true and albumId=null
const photosWithShowInPhotos = await prisma.photo.findMany({
where: {
showInPhotos: true,
albumId: null
},
select: {
id: true,
slug: true,
filename: true,
url: true,
status: true,
showInPhotos: true,
albumId: true,
publishedAt: true,
createdAt: true,
title: true,
description: true,
caption: true
},
orderBy: { createdAt: 'desc' }
})
// Query 2: Get count of photos by status with showInPhotos=true and albumId=null
const photosByStatus = await prisma.photo.groupBy({
by: ['status'],
where: {
showInPhotos: true,
albumId: null
},
_count: {
id: true
}
})
// Query 3: Get all photos regardless of status to see what exists
const allPhotosNoAlbum = await prisma.photo.findMany({
where: {
albumId: null
},
select: {
id: true,
slug: true,
filename: true,
status: true,
showInPhotos: true,
albumId: true,
publishedAt: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
})
// Query 3b: Get ALL photos to see what's in the database
const allPhotos = await prisma.photo.findMany({
select: {
id: true,
slug: true,
filename: true,
status: true,
showInPhotos: true,
albumId: true,
publishedAt: true,
createdAt: true,
album: {
select: {
title: true
}
}
},
orderBy: { createdAt: 'desc' }
})
// Query 4: Get specific published photos that should appear
const publishedPhotos = await prisma.photo.findMany({
where: {
status: 'published',
showInPhotos: true,
albumId: null
},
select: {
id: true,
slug: true,
filename: true,
url: true,
status: true,
showInPhotos: true,
albumId: true,
publishedAt: true,
createdAt: true,
title: true
}
})
// Query 5: Raw SQL query to double-check
const rawQuery = await prisma.$queryRaw`
SELECT id, slug, filename, status, "showInPhotos", "albumId", "publishedAt", "createdAt"
FROM "Photo"
WHERE "showInPhotos" = true AND "albumId" IS NULL
ORDER BY "createdAt" DESC
`
// Query 6: Get all albums and their isPhotography flag
const allAlbums = await prisma.album.findMany({
select: {
id: true,
title: true,
slug: true,
isPhotography: true,
status: true,
createdAt: true,
_count: {
select: {
photos: true
}
}
},
orderBy: { id: 'asc' }
})
// Query 7: Get photos from albums with isPhotography=true
const photosFromPhotographyAlbums = await prisma.photo.findMany({
where: {
album: {
isPhotography: true
}
},
select: {
id: true,
slug: true,
filename: true,
status: true,
showInPhotos: true,
albumId: true,
album: {
select: {
id: true,
title: true,
isPhotography: true
}
}
}
})
// Query 8: Specifically check album with ID 5
const albumFive = await prisma.album.findUnique({
where: { id: 5 },
include: {
photos: {
select: {
id: true,
slug: true,
filename: true,
status: true,
showInPhotos: true
}
}
}
})
const response = {
summary: {
totalPhotosWithShowInPhotos: photosWithShowInPhotos.length,
totalPublishedPhotos: publishedPhotos.length,
totalPhotosNoAlbum: allPhotosNoAlbum.length,
totalPhotosInDatabase: allPhotos.length,
photosByStatus: photosByStatus.map(item => ({
status: item.status,
count: item._count.id
})),
photosWithShowInPhotosFlag: allPhotos.filter(p => p.showInPhotos).length,
photosByFilename: allPhotos.filter(p => p.filename?.includes('B0000057')).map(p => ({
filename: p.filename,
showInPhotos: p.showInPhotos,
status: p.status,
albumId: p.albumId,
albumTitle: p.album?.title
}))
},
albums: {
totalAlbums: allAlbums.length,
photographyAlbums: allAlbums.filter(a => a.isPhotography).map(a => ({
id: a.id,
title: a.title,
slug: a.slug,
isPhotography: a.isPhotography,
status: a.status,
photoCount: a._count.photos
})),
nonPhotographyAlbums: allAlbums.filter(a => !a.isPhotography).map(a => ({
id: a.id,
title: a.title,
slug: a.slug,
isPhotography: a.isPhotography,
status: a.status,
photoCount: a._count.photos
})),
albumFive: albumFive ? {
id: albumFive.id,
title: albumFive.title,
slug: albumFive.slug,
isPhotography: albumFive.isPhotography,
status: albumFive.status,
publishedAt: albumFive.publishedAt,
photoCount: albumFive.photos.length,
photos: albumFive.photos
} : null,
photosFromPhotographyAlbums: photosFromPhotographyAlbums.length,
photosFromPhotographyAlbumsSample: photosFromPhotographyAlbums.slice(0, 5)
},
queries: {
photosWithShowInPhotos: photosWithShowInPhotos,
publishedPhotos: publishedPhotos,
allPhotosNoAlbum: allPhotosNoAlbum,
allPhotos: allPhotos,
rawQueryResults: rawQuery,
allAlbums: allAlbums
},
debug: {
expectedQuery: 'WHERE status = "published" AND showInPhotos = true AND albumId = null',
actualPhotosEndpointQuery: '/api/photos uses this exact query',
albumsWithPhotographyFlagTrue: allAlbums.filter(a => a.isPhotography).map(a => `${a.id}: ${a.title}`)
}
}
logger.info('Test photos query results', response.summary)
return jsonResponse(response)
} catch (error) {
logger.error('Failed to run test photos query', error as Error)
return errorResponse(`Failed to run test query: ${error instanceof Error ? error.message : 'Unknown error'}`, 500)
}
}

View file

@ -7,7 +7,9 @@
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 PhotoGrid
@ -35,7 +37,7 @@
// Generate metadata
const metaTags = $derived(
album
type === 'album' && album
? generateMetaTags({
title: album.title,
description:
@ -45,9 +47,17 @@
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: 'Album Not Found',
description: 'The album you are looking for could not be found.',
title: 'Not Found',
description: 'The content you are looking for could not be found.',
url: pageUrl,
noindex: true
})
@ -55,7 +65,7 @@
// Generate image gallery JSON-LD
const galleryJsonLd = $derived(
album
type === 'album' && album
? generateImageGalleryJsonLd({
name: album.title,
description: album.description,
@ -66,6 +76,15 @@
caption: photo.caption
})) || []
})
: 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>
@ -96,12 +115,12 @@
{#if error}
<div class="error-container">
<div class="error-message">
<h1>Album Not Found</h1>
<h1>Not Found</h1>
<p>{error}</p>
<BackButton href="/photos" label="Back to Photos" />
</div>
</div>
{:else if album}
{:else if type === 'album' && album}
<div class="album-page">
<!-- Album Card -->
<div class="album-card">
@ -133,6 +152,36 @@
</div>
{/if}
</div>
{:else if type === 'photo' && photo}
<div class="photo-page">
<div class="photo-header">
<BackButton href="/photos" label="Back to Photos" />
</div>
<div class="photo-container">
<img
src={photo.url}
alt={photo.title || photo.caption || 'Photo'}
class="photo-image"
/>
</div>
<div class="photo-info">
{#if photo.title}
<h1 class="photo-title">{photo.title}</h1>
{/if}
{#if photo.caption || photo.description}
<p class="photo-description">{photo.caption || photo.description}</p>
{/if}
{#if photo.exifData}
<div class="photo-exif">
<!-- EXIF data could be displayed here -->
</div>
{/if}
</div>
</div>
{/if}
<style lang="scss">
@ -240,4 +289,55 @@
padding: $unit-6x $unit-3x;
color: $grey-40;
}
.photo-page {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: $unit-4x $unit-3x;
@include breakpoint('phone') {
padding: $unit-3x $unit-2x;
}
}
.photo-header {
margin-bottom: $unit-3x;
}
.photo-container {
margin-bottom: $unit-4x;
text-align: center;
.photo-image {
max-width: 100%;
height: auto;
border-radius: $card-corner-radius;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
}
.photo-info {
max-width: 700px;
margin: 0 auto;
text-align: center;
.photo-title {
font-size: 2rem;
font-weight: 700;
margin: 0 0 $unit-2x;
color: $grey-10;
@include breakpoint('phone') {
font-size: 1.5rem;
}
}
.photo-description {
font-size: 1rem;
color: $grey-30;
line-height: 1.6;
margin: 0 0 $unit-3x;
}
}
</style>

View file

@ -2,30 +2,41 @@ import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params, fetch }) => {
try {
// Fetch the specific album using the individual album endpoint which includes photos
const response = await fetch(`/api/albums/by-slug/${params.slug}`)
if (!response.ok) {
if (response.status === 404) {
throw new Error('Album not found')
// 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 this is a photography album and published
if (album.isPhotography && album.status === 'published') {
return {
type: 'album' as const,
album,
photo: null
}
}
throw new Error('Failed to fetch album')
}
const album = await response.json()
// Check if this is a photography album and published
if (!album.isPhotography || album.status !== 'published') {
throw new Error('Album not found')
// 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
}
}
return {
album
}
// Neither album nor photo found
throw new Error('Content not found')
} catch (error) {
console.error('Error loading album:', error)
console.error('Error loading content:', error)
return {
type: null,
album: null,
error: error instanceof Error ? error.message : 'Failed to load album'
photo: null,
error: error instanceof Error ? error.message : 'Failed to load content'
}
}
}

View file

@ -0,0 +1,665 @@
<script lang="ts">
import BackButton from '$components/BackButton.svelte'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { onMount } from 'svelte'
import type { PageData } from './$types'
import { isAlbum } from '$lib/types/photos'
let { data }: { data: PageData } = $props()
const photo = $derived(data.photo)
const error = $derived(data.error)
const photoItems = $derived(data.photoItems || [])
const currentPhotoId = $derived(data.currentPhotoId)
let showModal = $state(false)
const pageUrl = $derived($page.url.href)
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
}
// Generate metadata
const metaTags = $derived(
photo
? generateMetaTags({
title: photo.title || 'Photo',
description: photo.description || photo.caption || 'A photograph',
url: pageUrl,
image: photo.url,
titleFormat: { type: 'by' }
})
: generateMetaTags({
title: 'Photo Not Found',
description: 'The photo you are looking for could not be found.',
url: pageUrl,
noindex: true
})
)
// Generate JSON-LD for photo
const photoJsonLd = $derived(
photo
? {
'@context': 'https://schema.org',
'@type': 'ImageObject',
name: photo.title || 'Photo',
description: photo.description || photo.caption,
contentUrl: photo.url,
url: pageUrl,
dateCreated: photo.createdAt,
author: {
'@type': 'Person',
name: '@jedmund'
}
}
: null
)
// Parse EXIF data if available
const exifData = $derived(
photo?.exifData && typeof photo.exifData === 'object' ? photo.exifData : null
)
// Get adjacent photos for filmstrip - always show 5 when possible
const filmstripItems = $derived(() => {
if (!photoItems.length || !currentPhotoId) return []
const currentIndex = photoItems.findIndex(item => item.id === currentPhotoId)
if (currentIndex === -1) return []
const targetCount = 5
const halfCount = Math.floor(targetCount / 2)
let start = currentIndex - halfCount
let end = currentIndex + halfCount + 1
// Adjust if we're near the beginning
if (start < 0) {
end = Math.min(photoItems.length, end - start)
start = 0
}
// Adjust if we're near the end
if (end > photoItems.length) {
start = Math.max(0, start - (end - photoItems.length))
end = photoItems.length
}
// Ensure we always get up to targetCount items if available
const itemsCount = end - start
if (itemsCount < targetCount && photoItems.length >= targetCount) {
if (start === 0) {
end = Math.min(targetCount, photoItems.length)
} else {
start = Math.max(0, photoItems.length - targetCount)
}
}
return photoItems.slice(start, end)
})
// Handle filmstrip navigation
function handleFilmstripClick(item: any) {
if (isAlbum(item)) {
goto(`/photos/${item.slug}`)
} else {
const photoId = item.id.replace('photo-', '')
goto(`/photos/p/${photoId}`)
}
}
// Modal handlers
function openModal() {
showModal = true
document.body.style.overflow = 'hidden'
}
function closeModal() {
showModal = false
document.body.style.overflow = ''
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && showModal) {
closeModal()
}
}
// Set up keyboard listener
$effect(() => {
if (showModal) {
window.addEventListener('keydown', handleKeydown)
return () => window.removeEventListener('keydown', handleKeydown)
}
})
</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}
<!-- Canonical URL -->
<link rel="canonical" href={metaTags.other.canonical} />
<!-- JSON-LD -->
{#if photoJsonLd}
{@html `<script type="application/ld+json">${JSON.stringify(photoJsonLd)}</script>`}
{/if}
</svelte:head>
{#if error}
<div class="error-container">
<div class="error-message">
<h1>Photo Not Found</h1>
<p>{error}</p>
<BackButton href="/photos" label="Back to Photos" />
</div>
</div>
{:else if photo}
<div class="photo-page">
<button class="photo-container" onclick={openModal} type="button">
<img src={photo.url} alt={photo.title || photo.caption || 'Photo'} class="photo-image" />
</button>
<div class="photo-info-card">
{#if photo.title || photo.caption || photo.description}
<div class="photo-details">
{#if photo.title}
<h1 class="photo-title">{photo.title}</h1>
{/if}
{#if photo.caption || photo.description}
<p class="photo-description">{photo.caption || photo.description}</p>
{/if}
</div>
{/if}
{#if exifData || photo.createdAt}
<div class="metadata-grid">
{#if exifData?.camera}
<div class="metadata-item">
<span class="metadata-label">Camera</span>
<span class="metadata-value">{exifData.camera}</span>
</div>
{/if}
{#if exifData?.lens}
<div class="metadata-item">
<span class="metadata-label">Lens</span>
<span class="metadata-value">{exifData.lens}</span>
</div>
{/if}
{#if exifData?.focalLength}
<div class="metadata-item">
<span class="metadata-label">Focal Length</span>
<span class="metadata-value">{exifData.focalLength}</span>
</div>
{/if}
{#if exifData?.aperture}
<div class="metadata-item">
<span class="metadata-label">Aperture</span>
<span class="metadata-value">{exifData.aperture}</span>
</div>
{/if}
{#if exifData?.shutterSpeed}
<div class="metadata-item">
<span class="metadata-label">Shutter Speed</span>
<span class="metadata-value">{exifData.shutterSpeed}</span>
</div>
{/if}
{#if exifData?.iso}
<div class="metadata-item">
<span class="metadata-label">ISO</span>
<span class="metadata-value">{exifData.iso}</span>
</div>
{/if}
{#if exifData?.dateTaken}
<div class="metadata-item">
<span class="metadata-label">Date Taken</span>
<span class="metadata-value">{formatDate(exifData.dateTaken)}</span>
</div>
{:else if photo.createdAt}
<div class="metadata-item">
<span class="metadata-label">Date</span>
<span class="metadata-value">{formatDate(photo.createdAt)}</span>
</div>
{/if}
{#if exifData?.location}
<div class="metadata-item">
<span class="metadata-label">Location</span>
<span class="metadata-value">{exifData.location}</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Filmstrip Navigation -->
<div class="filmstrip-card">
<div class="filmstrip-container">
{#each filmstripItems() as item}
<button
class="filmstrip-item"
class:selected={item.id === currentPhotoId}
onclick={() => handleFilmstripClick(item)}
type="button"
>
{#if isAlbum(item)}
<img
src={item.coverPhoto.src}
alt={item.title}
class="filmstrip-image"
/>
<div class="album-indicator">
<span class="album-count">{item.photos.length}</span>
</div>
{:else}
<img
src={item.src}
alt={item.alt}
class="filmstrip-image"
/>
{/if}
</button>
{/each}
</div>
<div class="card-footer">
<BackButton
href={photo.album ? `/photos/${photo.album.slug}` : '/photos'}
label={photo.album ? `Back to ${photo.album.title}` : 'Back to Photos'}
/>
</div>
</div>
</div>
<!-- Photo Modal -->
{#if showModal}
<div
class="photo-modal"
onclick={closeModal}
role="dialog"
aria-modal="true"
aria-label="Full size photo"
>
<div class="modal-content">
<img
src={photo.url}
alt={photo.title || photo.caption || 'Photo'}
class="modal-image"
onclick={closeModal}
/>
</div>
</div>
{/if}
{/if}
<style lang="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;
@include breakpoint('tablet') {
max-width: 900px;
}
@include breakpoint('phone') {
padding: 0 $unit-2x $unit-2x;
gap: $unit;
}
}
.photo-container {
max-width: 700px;
width: 100%;
font-size: 0;
line-height: 0;
border: none;
padding: 0;
background: none;
cursor: zoom-in;
position: relative;
&::before {
content: '';
position: absolute;
inset: -3px;
border-radius: $image-corner-radius;
border: 3px solid transparent;
z-index: 2;
pointer-events: none;
transition: border-color 0.2s ease;
}
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: $image-corner-radius;
border: 2px solid transparent;
z-index: 3;
pointer-events: none;
transition: border-color 0.2s ease;
}
&:focus-visible {
outline: none;
&::before {
border-color: $red-60;
}
&::after {
border-color: $grey-100;
}
}
&:hover {
.photo-image {
opacity: 0.95;
}
}
.photo-image {
display: block;
max-width: 100%;
width: 100%;
height: auto;
object-fit: contain;
border-radius: $image-corner-radius;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: opacity 0.2s ease;
@include breakpoint('phone') {
border-radius: $image-corner-radius;
}
}
}
.photo-info-card {
background: $grey-100;
border: 1px solid $grey-90;
border-radius: $image-corner-radius;
padding: $unit-4x;
max-width: 700px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
@include breakpoint('phone') {
padding: $unit-3x;
max-width: 100%;
}
}
.photo-details {
margin-bottom: $unit-4x;
padding-bottom: $unit-4x;
border-bottom: 1px solid $grey-90;
text-align: center;
@include breakpoint('phone') {
margin-bottom: $unit-3x;
padding-bottom: $unit-3x;
}
.photo-title {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 $unit-2x;
color: $grey-10;
@include breakpoint('phone') {
font-size: 1.25rem;
margin-bottom: $unit;
}
}
.photo-description {
font-size: 1rem;
color: $grey-30;
line-height: 1.6;
margin: 0;
@include breakpoint('phone') {
font-size: 0.875rem;
}
}
}
.metadata-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: $unit-3x;
@include breakpoint('phone') {
grid-template-columns: 1fr;
gap: $unit-2x;
}
}
.metadata-item {
display: flex;
flex-direction: column;
gap: $unit-half;
.metadata-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $grey-40;
}
.metadata-value {
font-size: 0.875rem;
color: $grey-10;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New',
monospace;
}
}
// Filmstrip Navigation
.filmstrip-card {
background: $grey-100;
border: 1px solid $grey-90;
border-radius: $image-corner-radius;
padding: $unit-4x;
max-width: 700px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
@include breakpoint('phone') {
padding: $unit-3x;
max-width: 100%;
}
}
.filmstrip-container {
display: flex;
justify-content: center;
align-items: center;
gap: $unit-2x;
margin-bottom: $unit-2x;
padding: $unit 0;
@include breakpoint('phone') {
gap: $unit;
}
}
.filmstrip-item {
flex: 0 0 auto;
height: 100px;
position: relative;
border: none;
padding: 0;
background: none;
cursor: pointer;
border-radius: $corner-radius-md;
overflow: hidden;
transition: all 0.2s ease;
opacity: 0.6;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: $corner-radius-md;
border: 3px solid transparent;
z-index: 2;
pointer-events: none;
transition: border-color 0.2s ease;
}
&::after {
content: '';
position: absolute;
inset: 3px;
border-radius: calc($corner-radius-md - 3px);
border: 2px solid transparent;
z-index: 3;
pointer-events: none;
transition: border-color 0.2s ease;
}
&:hover {
opacity: 1;
transform: scale(1.02);
}
&.selected {
opacity: 1;
&::before {
border-color: $red-60;
}
&::after {
border-color: $grey-100;
}
}
@include breakpoint('phone') {
height: 70px;
}
}
.filmstrip-image {
height: 100%;
width: auto;
object-fit: cover;
display: block;
}
.album-indicator {
position: absolute;
bottom: $unit-half;
right: $unit-half;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: $corner-radius-xs;
font-size: 0.75rem;
font-weight: 600;
}
.card-footer {
display: flex;
justify-content: center;
}
// Modal styles
.photo-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: zoom-out;
padding: $unit-2x;
box-sizing: border-box;
}
.modal-content {
position: relative;
max-width: 95vw;
max-height: 95vh;
cursor: default;
}
.modal-image {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 95vh;
object-fit: contain;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
cursor: zoom-out;
}
</style>

View file

@ -0,0 +1,42 @@
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params, fetch }) => {
try {
const photoId = parseInt(params.id)
if (isNaN(photoId)) {
throw new Error('Invalid photo ID')
}
// Fetch the photo by ID
const photoResponse = await fetch(`/api/photos/${photoId}`)
if (!photoResponse.ok) {
if (photoResponse.status === 404) {
throw new Error('Photo not found')
}
throw new Error('Failed to fetch photo')
}
const photo = await photoResponse.json()
// Fetch all photos for the filmstrip navigation
const allPhotosResponse = await fetch('/api/photos?limit=100')
let photoItems = []
if (allPhotosResponse.ok) {
const data = await allPhotosResponse.json()
photoItems = data.photoItems || []
}
return {
photo,
photoItems,
currentPhotoId: `photo-${photoId}`
}
} catch (error) {
console.error('Error loading photo:', error)
return {
photo: null,
photoItems: [],
error: error instanceof Error ? error.message : 'Failed to load photo'
}
}
}