Better single photo view
This commit is contained in:
parent
8bc9b9e1e4
commit
610a421207
25 changed files with 2540 additions and 468 deletions
187
prd/PRD-dominant-color-extraction.md
Normal file
187
prd/PRD-dominant-color-extraction.md
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
113
scripts/check-photos-display.ts
Normal file
113
scripts/check-photos-display.ts
Normal 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
42
scripts/debug-photos.md
Normal 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
199
scripts/test-media-sharing.ts
Executable 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()
|
||||
106
scripts/test-photos-query.ts
Normal file
106
scripts/test-photos-query.ts
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface Photo {
|
|||
width: number
|
||||
height: number
|
||||
exif?: ExifData
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface PhotoAlbum {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
241
src/routes/api/test-photos/+server.ts
Normal file
241
src/routes/api/test-photos/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
665
src/routes/photos/p/[id]/+page.svelte
Normal file
665
src/routes/photos/p/[id]/+page.svelte
Normal 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>
|
||||
42
src/routes/photos/p/[id]/+page.ts
Normal file
42
src/routes/photos/p/[id]/+page.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue