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 {
|
model Photo {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
albumId Int?
|
albumId Int?
|
||||||
|
mediaId Int? // Reference to the Media item
|
||||||
filename String @db.VarChar(255)
|
filename String @db.VarChar(255)
|
||||||
url String @db.VarChar(500)
|
url String @db.VarChar(500)
|
||||||
thumbnailUrl String? @db.VarChar(500)
|
thumbnailUrl String? @db.VarChar(500)
|
||||||
|
|
@ -107,9 +108,11 @@ model Photo {
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
album Album? @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
album Album? @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
||||||
|
media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([mediaId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media table (general uploads)
|
// Media table (general uploads)
|
||||||
|
|
@ -133,6 +136,7 @@ model Media {
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
usage MediaUsage[]
|
usage MediaUsage[]
|
||||||
|
photos Photo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media usage tracking table
|
// 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
|
// Get auth headers for API requests
|
||||||
export function getAuthHeaders(): HeadersInit {
|
export function getAuthHeaders(): HeadersInit {
|
||||||
if (!adminCredentials) {
|
// First try to get from localStorage (where login stores it)
|
||||||
// For development, use default credentials
|
const storedAuth = typeof window !== 'undefined' ? localStorage.getItem('admin_auth') : null
|
||||||
// In production, this should redirect to login
|
if (storedAuth) {
|
||||||
adminCredentials = btoa('admin:localdev')
|
return {
|
||||||
|
Authorization: `Basic ${storedAuth}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to in-memory credentials if set
|
||||||
|
if (adminCredentials) {
|
||||||
return {
|
return {
|
||||||
Authorization: `Basic ${adminCredentials}`
|
Authorization: `Basic ${adminCredentials}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Development fallback
|
||||||
|
const fallbackAuth = btoa('admin:localdev')
|
||||||
|
return {
|
||||||
|
Authorization: `Basic ${fallbackAuth}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user is authenticated (basic check)
|
// Check if user is authenticated (basic check)
|
||||||
export function isAuthenticated(): boolean {
|
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)
|
// Clear auth (logout)
|
||||||
export function clearAuth() {
|
export function clearAuth() {
|
||||||
adminCredentials = null
|
adminCredentials = null
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('admin_auth')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make authenticated API request
|
// Make authenticated API request
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@
|
||||||
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
|
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
|
||||||
goto(`/photos/${albumSlug}/${photoId}`)
|
goto(`/photos/${albumSlug}/${photoId}`)
|
||||||
} else {
|
} else {
|
||||||
// For standalone photos, navigate to a generic photo page (to be implemented)
|
// Navigate to individual photo page using the photo ID
|
||||||
console.log('Individual photo navigation not yet implemented')
|
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
|
||||||
|
goto(`/photos/p/${photoId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import Input from './Input.svelte'
|
import Input from './Input.svelte'
|
||||||
import SmartImage from '../SmartImage.svelte'
|
import SmartImage from '../SmartImage.svelte'
|
||||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||||
|
import MediaDetailsModal from './MediaDetailsModal.svelte'
|
||||||
import { authenticatedFetch } from '$lib/admin-auth'
|
import { authenticatedFetch } from '$lib/admin-auth'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -20,6 +21,7 @@
|
||||||
helpText?: string
|
helpText?: string
|
||||||
showBrowseLibrary?: boolean
|
showBrowseLibrary?: boolean
|
||||||
maxFileSize?: number // MB limit
|
maxFileSize?: number // MB limit
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -35,7 +37,8 @@
|
||||||
placeholder = 'Drag and drop images here, or click to browse',
|
placeholder = 'Drag and drop images here, or click to browse',
|
||||||
helpText,
|
helpText,
|
||||||
showBrowseLibrary = false,
|
showBrowseLibrary = false,
|
||||||
maxFileSize = 10
|
maxFileSize = 10,
|
||||||
|
disabled = false
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
@ -47,6 +50,8 @@
|
||||||
let draggedIndex = $state<number | null>(null)
|
let draggedIndex = $state<number | null>(null)
|
||||||
let draggedOverIndex = $state<number | null>(null)
|
let draggedOverIndex = $state<number | null>(null)
|
||||||
let isMediaLibraryOpen = $state(false)
|
let isMediaLibraryOpen = $state(false)
|
||||||
|
let isImageModalOpen = $state(false)
|
||||||
|
let selectedImage = $state<any | null>(null)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const hasImages = $derived(value && value.length > 0)
|
const hasImages = $derived(value && value.length > 0)
|
||||||
|
|
@ -93,7 +98,7 @@
|
||||||
|
|
||||||
// Handle file selection/drop
|
// Handle file selection/drop
|
||||||
async function handleFiles(files: FileList) {
|
async function handleFiles(files: FileList) {
|
||||||
if (files.length === 0) return
|
if (files.length === 0 || disabled) return
|
||||||
|
|
||||||
// Validate files
|
// Validate files
|
||||||
const filesToUpload: File[] = []
|
const filesToUpload: File[] = []
|
||||||
|
|
@ -150,8 +155,13 @@
|
||||||
|
|
||||||
// Brief delay to show completion
|
// Brief delay to show completion
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const newValue = [...(value || []), ...uploadedMedia]
|
console.log('[GalleryUploader] Upload completed:', {
|
||||||
value = newValue
|
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
|
// Only pass the newly uploaded media, not the entire gallery
|
||||||
onUpload(uploadedMedia)
|
onUpload(uploadedMedia)
|
||||||
isUploading = false
|
isUploading = false
|
||||||
|
|
@ -214,53 +224,26 @@
|
||||||
uploadError = null
|
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
|
// Drag and drop reordering handlers
|
||||||
function handleImageDragStart(event: DragEvent, index: number) {
|
function handleImageDragStart(event: DragEvent, index: number) {
|
||||||
|
// Prevent reordering while uploading or disabled
|
||||||
|
if (isUploading || disabled) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
draggedIndex = index
|
draggedIndex = index
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('[GalleryUploader] Drag start:', {
|
||||||
|
index,
|
||||||
|
item: value[index],
|
||||||
|
totalItems: value.length
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleImageDragOver(event: DragEvent, index: number) {
|
function handleImageDragOver(event: DragEvent, index: number) {
|
||||||
|
|
@ -278,7 +261,20 @@
|
||||||
function handleImageDrop(event: DragEvent, dropIndex: number) {
|
function handleImageDrop(event: DragEvent, dropIndex: number) {
|
||||||
event.preventDefault()
|
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 newValue = [...value]
|
||||||
const draggedItem = newValue[draggedIndex]
|
const draggedItem = newValue[draggedIndex]
|
||||||
|
|
@ -290,6 +286,17 @@
|
||||||
const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
|
const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
|
||||||
newValue.splice(adjustedDropIndex, 0, draggedItem)
|
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
|
value = newValue
|
||||||
onUpload(newValue)
|
onUpload(newValue)
|
||||||
if (onReorder) {
|
if (onReorder) {
|
||||||
|
|
@ -314,6 +321,13 @@
|
||||||
// For gallery mode, selectedMedia will be an array
|
// For gallery mode, selectedMedia will be an array
|
||||||
const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia]
|
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
|
// Filter out duplicates before passing to parent
|
||||||
// Create a comprehensive set of existing IDs (both id and mediaId)
|
// Create a comprehensive set of existing IDs (both id and mediaId)
|
||||||
const existingIds = new Set()
|
const existingIds = new Set()
|
||||||
|
|
@ -327,6 +341,11 @@
|
||||||
return !existingIds.has(media.id) && !existingIds.has(media.mediaId)
|
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) {
|
if (newMedia.length > 0) {
|
||||||
// Don't modify the value array here - let the parent component handle it
|
// Don't modify the value array here - let the parent component handle it
|
||||||
// through the API calls and then update the bound value
|
// through the API calls and then update the bound value
|
||||||
|
|
@ -337,6 +356,49 @@
|
||||||
function handleMediaLibraryClose() {
|
function handleMediaLibraryClose() {
|
||||||
isMediaLibraryOpen = false
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="gallery-uploader">
|
<div class="gallery-uploader">
|
||||||
|
|
@ -347,10 +409,11 @@
|
||||||
class:drag-over={isDragOver}
|
class:drag-over={isDragOver}
|
||||||
class:uploading={isUploading}
|
class:uploading={isUploading}
|
||||||
class:has-error={!!uploadError}
|
class:has-error={!!uploadError}
|
||||||
ondragover={handleDragOver}
|
class:disabled={disabled}
|
||||||
ondragleave={handleDragLeave}
|
ondragover={disabled ? undefined : handleDragOver}
|
||||||
ondrop={handleDrop}
|
ondragleave={disabled ? undefined : handleDragLeave}
|
||||||
onclick={handleBrowseClick}
|
ondrop={disabled ? undefined : handleDrop}
|
||||||
|
onclick={disabled ? undefined : handleBrowseClick}
|
||||||
>
|
>
|
||||||
{#if isUploading}
|
{#if isUploading}
|
||||||
<!-- Upload Progress -->
|
<!-- Upload Progress -->
|
||||||
|
|
@ -461,12 +524,12 @@
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
{#if !isUploading && canAddMore}
|
{#if !isUploading && canAddMore}
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<Button variant="primary" onclick={handleBrowseClick}>
|
<Button variant="primary" onclick={handleBrowseClick} disabled={disabled}>
|
||||||
{hasImages ? 'Add More Images' : 'Choose Images'}
|
{hasImages ? 'Add More Images' : 'Choose Images'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{#if showBrowseLibrary}
|
{#if showBrowseLibrary}
|
||||||
<Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
|
<Button variant="ghost" onclick={handleBrowseLibrary} disabled={disabled}>Browse Library</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -474,12 +537,13 @@
|
||||||
<!-- Image Gallery -->
|
<!-- Image Gallery -->
|
||||||
{#if hasImages}
|
{#if hasImages}
|
||||||
<div class="image-gallery">
|
<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
|
<div
|
||||||
class="gallery-item"
|
class="gallery-item"
|
||||||
class:dragging={draggedIndex === index}
|
class:dragging={draggedIndex === index}
|
||||||
class:drag-over={draggedOverIndex === index}
|
class:drag-over={draggedOverIndex === index}
|
||||||
draggable="true"
|
class:disabled={disabled}
|
||||||
|
draggable={!disabled}
|
||||||
ondragstart={(e) => handleImageDragStart(e, index)}
|
ondragstart={(e) => handleImageDragStart(e, index)}
|
||||||
ondragover={(e) => handleImageDragOver(e, index)}
|
ondragover={(e) => handleImageDragOver(e, index)}
|
||||||
ondragleave={handleImageDragLeave}
|
ondragleave={handleImageDragLeave}
|
||||||
|
|
@ -506,6 +570,13 @@
|
||||||
|
|
||||||
<!-- Image Preview -->
|
<!-- Image Preview -->
|
||||||
<div class="image-preview">
|
<div class="image-preview">
|
||||||
|
<button
|
||||||
|
class="image-button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleImageClick(media)}
|
||||||
|
aria-label="Edit image {media.filename}"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
media={{
|
media={{
|
||||||
id: media.mediaId || media.id,
|
id: media.mediaId || media.id,
|
||||||
|
|
@ -529,13 +600,18 @@
|
||||||
aspectRatio="1:1"
|
aspectRatio="1:1"
|
||||||
class="gallery-image"
|
class="gallery-image"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Remove Button -->
|
<!-- Remove Button -->
|
||||||
<button
|
<button
|
||||||
class="remove-button"
|
class="remove-button"
|
||||||
onclick={() => handleRemoveImage(index)}
|
onclick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRemoveImage(index)
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Remove image"
|
aria-label="Remove image"
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
|
|
@ -568,20 +644,6 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- File Info -->
|
||||||
<div class="file-info">
|
<div class="file-info">
|
||||||
<p class="filename">{media.originalName || media.filename}</p>
|
<p class="filename">{media.originalName || media.filename}</p>
|
||||||
|
|
@ -624,6 +686,17 @@
|
||||||
onClose={handleMediaLibraryClose}
|
onClose={handleMediaLibraryClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Media Details Modal -->
|
||||||
|
<MediaDetailsModal
|
||||||
|
bind:isOpen={isImageModalOpen}
|
||||||
|
media={selectedImage}
|
||||||
|
onClose={() => {
|
||||||
|
isImageModalOpen = false
|
||||||
|
selectedImage = null
|
||||||
|
}}
|
||||||
|
onUpdate={handleImageUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.gallery-uploader {
|
.gallery-uploader {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -683,6 +756,16 @@
|
||||||
border-color: $red-60;
|
border-color: $red-60;
|
||||||
background-color: rgba($red-60, 0.02);
|
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 {
|
.upload-prompt {
|
||||||
|
|
@ -828,19 +911,51 @@
|
||||||
&:hover .drag-handle {
|
&:hover .drag-handle {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .drag-handle {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-preview {
|
.image-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: $grey-97;
|
||||||
|
|
||||||
|
.image-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
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) {
|
:global(.gallery-image) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.remove-button {
|
.remove-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -858,12 +973,17 @@
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: white;
|
background: white;
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .remove-button {
|
&:hover .remove-button {
|
||||||
|
|
@ -871,9 +991,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.alt-text-input {
|
|
||||||
padding: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-info {
|
.file-info {
|
||||||
padding: $unit-2x;
|
padding: $unit-2x;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import Modal from './Modal.svelte'
|
import Modal from './Modal.svelte'
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
import Input from './Input.svelte'
|
import Input from './Input.svelte'
|
||||||
|
import Textarea from './Textarea.svelte'
|
||||||
import SmartImage from '../SmartImage.svelte'
|
import SmartImage from '../SmartImage.svelte'
|
||||||
import { authenticatedFetch } from '$lib/admin-auth'
|
import { authenticatedFetch } from '$lib/admin-auth'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
@ -36,6 +37,9 @@
|
||||||
>([])
|
>([])
|
||||||
let loadingUsage = $state(false)
|
let loadingUsage = $state(false)
|
||||||
|
|
||||||
|
// EXIF toggle state
|
||||||
|
let showExif = $state(false)
|
||||||
|
|
||||||
// Initialize form when media changes
|
// Initialize form when media changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (media) {
|
if (media) {
|
||||||
|
|
@ -44,6 +48,7 @@
|
||||||
isPhotography = media.isPhotography || false
|
isPhotography = media.isPhotography || false
|
||||||
error = ''
|
error = ''
|
||||||
successMessage = ''
|
successMessage = ''
|
||||||
|
showExif = false
|
||||||
loadUsage()
|
loadUsage()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -190,46 +195,19 @@
|
||||||
{#if media}
|
{#if media}
|
||||||
<Modal
|
<Modal
|
||||||
bind:isOpen
|
bind:isOpen
|
||||||
size="large"
|
size="jumbo"
|
||||||
closeOnBackdrop={!isSaving}
|
closeOnBackdrop={!isSaving}
|
||||||
closeOnEscape={!isSaving}
|
closeOnEscape={!isSaving}
|
||||||
on:close={handleClose}
|
on:close={handleClose}
|
||||||
|
showCloseButton={false}
|
||||||
>
|
>
|
||||||
<div class="media-details-modal">
|
<div class="media-details-modal">
|
||||||
<!-- Header -->
|
<!-- Left Pane - Image Preview -->
|
||||||
<div class="modal-header">
|
<div class="image-pane">
|
||||||
<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">
|
|
||||||
<svg
|
|
||||||
slot="icon"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6 6L18 18M6 18L18 6"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="media-preview-section">
|
|
||||||
<!-- Media Preview -->
|
|
||||||
<div class="media-preview">
|
|
||||||
{#if media.mimeType.startsWith('image/')}
|
{#if media.mimeType.startsWith('image/')}
|
||||||
<SmartImage {media} alt={media.altText || media.filename} />
|
<div class="image-container">
|
||||||
|
<SmartImage {media} alt={media.altText || media.filename} class="preview-image" />
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="file-placeholder">
|
<div class="file-placeholder">
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -259,60 +237,155 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
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="M6 6L18 18M6 18L18 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="pane-body">
|
||||||
<!-- File Info -->
|
<!-- File Info -->
|
||||||
<div class="file-info">
|
<div class="file-info">
|
||||||
<div class="info-row">
|
<div class="info-grid">
|
||||||
<span class="label">Type:</span>
|
<div class="info-item">
|
||||||
|
<span class="label">Type</span>
|
||||||
<span class="value">{getFileType(media.mimeType)}</span>
|
<span class="value">{getFileType(media.mimeType)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-item">
|
||||||
<span class="label">Size:</span>
|
<span class="label">Size</span>
|
||||||
<span class="value">{formatFileSize(media.size)}</span>
|
<span class="value">{formatFileSize(media.size)}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if media.width && media.height}
|
{#if media.width && media.height}
|
||||||
<div class="info-row">
|
<div class="info-item">
|
||||||
<span class="label">Dimensions:</span>
|
<span class="label">Dimensions</span>
|
||||||
<span class="value">{media.width} × {media.height}px</span>
|
<span class="value">{media.width} × {media.height}px</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="info-row">
|
<div class="info-item">
|
||||||
<span class="label">Uploaded:</span>
|
<span class="label">Uploaded</span>
|
||||||
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
|
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Form -->
|
{#if media.exifData && Object.keys(media.exifData).length > 0}
|
||||||
<div class="edit-form">
|
{#if showExif}
|
||||||
<h3>Accessibility & SEO</h3>
|
<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
|
<Button
|
||||||
type="text"
|
variant="ghost"
|
||||||
label="Alt Text"
|
onclick={() => (showExif = !showExif)}
|
||||||
bind:value={altText}
|
buttonSize="small"
|
||||||
placeholder="Describe this image for screen readers"
|
|
||||||
helpText="Help make your content accessible. Describe what's in the image."
|
|
||||||
disabled={isSaving}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
pill={false}
|
||||||
|
class="exif-toggle"
|
||||||
<Input
|
>
|
||||||
type="textarea"
|
{showExif ? 'Hide EXIF' : 'Show EXIF'}
|
||||||
label="Description (Optional)"
|
</Button>
|
||||||
bind:value={description}
|
{/if}
|
||||||
placeholder="Additional description or caption"
|
</div>
|
||||||
helpText="Optional longer description for context or captions."
|
|
||||||
rows={3}
|
|
||||||
disabled={isSaving}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Photography Toggle -->
|
<!-- Photography Toggle -->
|
||||||
<div class="photography-toggle">
|
<div class="photography-toggle">
|
||||||
|
|
@ -323,15 +396,34 @@
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
class="toggle-input"
|
class="toggle-input"
|
||||||
/>
|
/>
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
<div class="toggle-content">
|
<div class="toggle-content">
|
||||||
<span class="toggle-title">Photography</span>
|
<span class="toggle-title">Show in Photos</span>
|
||||||
<span class="toggle-description">Show this media in the photography experience</span
|
<span class="toggle-description">This photo will be displayed in Photos</span>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
<!-- Usage Tracking -->
|
||||||
<div class="usage-section">
|
<div class="usage-section">
|
||||||
<h4>Used In</h4>
|
<h4>Used In</h4>
|
||||||
|
|
@ -378,9 +470,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="modal-footer">
|
<div class="pane-footer">
|
||||||
<div class="footer-left">
|
<div class="footer-left">
|
||||||
<Button variant="ghost" onclick={handleDelete} disabled={isSaving} class="delete-button">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onclick={handleDelete}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="delete-button"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -393,87 +490,49 @@
|
||||||
<span class="success-text">{successMessage}</span>
|
<span class="success-text">{successMessage}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Button variant="ghost" onclick={handleClose} disabled={isSaving}>Cancel</Button>
|
|
||||||
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
|
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
|
||||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.media-details-modal {
|
.media-details-modal {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: 90vh;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
// Left pane - Image preview
|
||||||
|
.image-pane {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
padding: $unit-4x;
|
padding: $unit-4x;
|
||||||
border-bottom: 1px solid $grey-90;
|
position: relative;
|
||||||
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;
|
overflow: hidden;
|
||||||
background: $grey-95;
|
|
||||||
|
.image-container {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
:global(img) {
|
:global(.preview-image) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
|
border-radius: $corner-radius-md;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-placeholder {
|
.file-placeholder {
|
||||||
|
|
@ -481,7 +540,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
color: $grey-50;
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
.file-type {
|
.file-type {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|
@ -490,41 +549,107 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
.file-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: $unit-2x;
|
gap: $unit-half;
|
||||||
|
|
||||||
|
&.vertical {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: $grey-30;
|
color: $grey-50;
|
||||||
min-width: 80px;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
color: $grey-10;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.url-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
.url-text {
|
|
||||||
color: $grey-10;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
word-break: break-all;
|
color: $grey-10;
|
||||||
flex: 1;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.btn.btn-ghost.exif-toggle) {
|
||||||
|
margin-top: $unit-2x;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid $grey-70;
|
||||||
|
|
||||||
|
&: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 {
|
.edit-form {
|
||||||
|
|
@ -532,13 +657,6 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit-4x;
|
gap: $unit-4x;
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: $grey-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -551,6 +669,7 @@
|
||||||
.toggle-label {
|
.toggle-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: $unit-3x;
|
gap: $unit-3x;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
@ -561,7 +680,7 @@
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
&:checked + .toggle-slider {
|
&:checked + .toggle-content + .toggle-slider {
|
||||||
background-color: $blue-60;
|
background-color: $blue-60;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
|
@ -569,7 +688,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled + .toggle-slider {
|
&:disabled + .toggle-content + .toggle-slider {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
@ -711,12 +830,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.pane-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: $unit-4x;
|
padding: $unit-2x $unit-3x;
|
||||||
border-top: 1px solid $grey-90;
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
.footer-left {
|
.footer-left {
|
||||||
|
|
@ -756,16 +875,30 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive adjustments
|
// Responsive adjustments
|
||||||
@include breakpoint('phone') {
|
@media (max-width: 768px) {
|
||||||
.modal-header {
|
.media-details-modal {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-pane {
|
||||||
|
height: 300px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-pane {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header {
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.pane-body {
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.pane-footer {
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit-3x;
|
gap: $unit-3x;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
|
|
||||||
export let isOpen = false
|
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 closeOnBackdrop = true
|
||||||
export let closeOnEscape = true
|
export let closeOnEscape = true
|
||||||
export let showCloseButton = true
|
export let showCloseButton = true
|
||||||
|
|
@ -118,6 +118,12 @@
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.modal-jumbo {
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 1400px;
|
||||||
|
height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
&.modal-full {
|
&.modal-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export interface Photo {
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
exif?: ExifData
|
exif?: ExifData
|
||||||
|
createdAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhotoAlbum {
|
export interface PhotoAlbum {
|
||||||
|
|
|
||||||
|
|
@ -180,9 +180,13 @@
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
await loadAlbums()
|
await loadAlbums()
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json()
|
||||||
|
error = errorData.error || 'Failed to delete album'
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete album:', err)
|
console.error('Failed to delete album:', err)
|
||||||
|
error = 'Failed to delete album. Please try again.'
|
||||||
} finally {
|
} finally {
|
||||||
showDeleteModal = false
|
showDeleteModal = false
|
||||||
albumToDelete = null
|
albumToDelete = null
|
||||||
|
|
@ -264,7 +268,7 @@
|
||||||
bind:isOpen={showDeleteModal}
|
bind:isOpen={showDeleteModal}
|
||||||
title="Delete album?"
|
title="Delete album?"
|
||||||
message={albumToDelete
|
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}
|
onConfirm={confirmDelete}
|
||||||
onCancel={cancelDelete}
|
onCancel={cancelDelete}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import GalleryUploader from '$lib/components/admin/GalleryUploader.svelte'
|
import GalleryUploader from '$lib/components/admin/GalleryUploader.svelte'
|
||||||
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
|
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
|
||||||
import AlbumMetadataPopover from '$lib/components/admin/AlbumMetadataPopover.svelte'
|
import AlbumMetadataPopover from '$lib/components/admin/AlbumMetadataPopover.svelte'
|
||||||
|
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
let album = $state<any>(null)
|
let album = $state<any>(null)
|
||||||
|
|
@ -28,6 +29,7 @@
|
||||||
let isLoading = $state(true)
|
let isLoading = $state(true)
|
||||||
let isSaving = $state(false)
|
let isSaving = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
|
let showDeleteModal = $state(false)
|
||||||
|
|
||||||
// Photo management state
|
// Photo management state
|
||||||
let isMediaLibraryOpen = $state(false)
|
let isMediaLibraryOpen = $state(false)
|
||||||
|
|
@ -153,7 +155,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
function handleDelete() {
|
||||||
|
showDeleteModal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
try {
|
try {
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
|
|
@ -175,9 +181,15 @@
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Failed to delete album'
|
error = err instanceof Error ? err.message : 'Failed to delete album'
|
||||||
console.error('Failed to delete album:', err)
|
console.error('Failed to delete album:', err)
|
||||||
|
} finally {
|
||||||
|
showDeleteModal = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
showDeleteModal = false
|
||||||
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
goto('/admin/albums')
|
goto('/admin/albums')
|
||||||
}
|
}
|
||||||
|
|
@ -380,6 +392,24 @@
|
||||||
|
|
||||||
async function handlePhotoReorder(reorderedPhotos: any[]) {
|
async function handlePhotoReorder(reorderedPhotos: any[]) {
|
||||||
try {
|
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')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
goto('/admin/login')
|
goto('/admin/login')
|
||||||
|
|
@ -403,11 +433,17 @@
|
||||||
|
|
||||||
await Promise.all(updatePromises)
|
await Promise.all(updatePromises)
|
||||||
|
|
||||||
// Update local state
|
// Update local state only after successful API calls
|
||||||
albumPhotos = reorderedPhotos
|
albumPhotos = [...reorderedPhotos]
|
||||||
|
|
||||||
|
console.log('[Album Edit] Reorder completed successfully')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Failed to reorder photos'
|
error = err instanceof Error ? err.message : 'Failed to reorder photos'
|
||||||
console.error('Failed to reorder photos:', err)
|
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)
|
// Handle new photos added through GalleryUploader (uploads or library selections)
|
||||||
async function handleGalleryAdd(newPhotos: any[]) {
|
async function handleGalleryAdd(newPhotos: any[]) {
|
||||||
try {
|
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) {
|
if (newPhotos.length > 0) {
|
||||||
// Check if these are new uploads (have File objects) or library selections (have media IDs)
|
// All items from GalleryUploader should be media objects, not Files
|
||||||
const uploadsToAdd = newPhotos.filter((photo) => photo instanceof File || !photo.id)
|
// They either come from uploads (already processed to Media) or library selections
|
||||||
const libraryPhotosToAdd = newPhotos.filter((photo) => photo.id && !(photo instanceof File))
|
await handleAddPhotos(newPhotos)
|
||||||
|
|
||||||
// Handle new uploads
|
|
||||||
if (uploadsToAdd.length > 0) {
|
|
||||||
await handleAddPhotosFromUpload(uploadsToAdd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle library selections
|
|
||||||
if (libraryPhotosToAdd.length > 0) {
|
|
||||||
await handleAddPhotos(libraryPhotosToAdd)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Failed to add photos'
|
error = err instanceof Error ? err.message : 'Failed to add photos'
|
||||||
|
|
@ -659,7 +696,8 @@
|
||||||
onRemove={handleGalleryRemove}
|
onRemove={handleGalleryRemove}
|
||||||
showBrowseLibrary={true}
|
showBrowseLibrary={true}
|
||||||
placeholder="Add photos to this album by uploading or selecting from your media library"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -702,6 +740,17 @@
|
||||||
onUpdate={handleMediaUpdate}
|
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">
|
<style lang="scss">
|
||||||
@import '$styles/variables.scss';
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ export const GET: RequestHandler = async (event) => {
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
photos: {
|
photos: {
|
||||||
orderBy: { displayOrder: 'asc' }
|
orderBy: { displayOrder: 'asc' },
|
||||||
|
include: {
|
||||||
|
media: true // Include media relation for each photo
|
||||||
|
}
|
||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: { photos: true }
|
select: { photos: true }
|
||||||
|
|
@ -32,35 +35,13 @@ export const GET: RequestHandler = async (event) => {
|
||||||
return errorResponse('Album not found', 404)
|
return errorResponse('Album not found', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all media usage records for this album's photos in one query
|
// Enrich photos with media information from the included relation
|
||||||
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
|
|
||||||
const photosWithMedia = album.photos.map((photo) => {
|
const photosWithMedia = album.photos.map((photo) => {
|
||||||
// Find the corresponding media usage record for this photo
|
const media = photo.media
|
||||||
const usage = mediaUsages.find((u) => u.media && u.media.filename === photo.filename)
|
|
||||||
const media = usage?.media
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...photo,
|
...photo,
|
||||||
mediaId: media?.id || null,
|
// Add media properties for backward compatibility
|
||||||
altText: media?.altText || '',
|
altText: media?.altText || '',
|
||||||
description: media?.description || photo.caption || '',
|
description: media?.description || photo.caption || '',
|
||||||
isPhotography: media?.isPhotography || false,
|
isPhotography: media?.isPhotography || false,
|
||||||
|
|
@ -184,17 +165,24 @@ export const DELETE: RequestHandler = async (event) => {
|
||||||
return errorResponse('Album not found', 404)
|
return errorResponse('Album not found', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if album has photos
|
// 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) {
|
if (album._count.photos > 0) {
|
||||||
return errorResponse('Cannot delete album that contains photos', 409)
|
await tx.photo.updateMany({
|
||||||
|
where: { albumId: id },
|
||||||
|
data: { albumId: null }
|
||||||
|
})
|
||||||
|
logger.info('Unlinked photos from album', { albumId: id, photoCount: album._count.photos })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete album
|
// Then delete the album
|
||||||
await prisma.album.delete({
|
await tx.album.delete({
|
||||||
where: { id }
|
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 })
|
return new Response(null, { status: 204 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -63,19 +63,24 @@ export const POST: RequestHandler = async (event) => {
|
||||||
displayOrder = (lastPhoto?.displayOrder || 0) + 1
|
displayOrder = (lastPhoto?.displayOrder || 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create photo record from media
|
// Create photo record linked to media
|
||||||
const photo = await prisma.photo.create({
|
const photo = await prisma.photo.create({
|
||||||
data: {
|
data: {
|
||||||
albumId,
|
albumId,
|
||||||
|
mediaId: body.mediaId, // Link to the Media record
|
||||||
filename: media.filename,
|
filename: media.filename,
|
||||||
url: media.url,
|
url: media.url,
|
||||||
thumbnailUrl: media.thumbnailUrl,
|
thumbnailUrl: media.thumbnailUrl,
|
||||||
width: media.width,
|
width: media.width,
|
||||||
height: media.height,
|
height: media.height,
|
||||||
|
exifData: media.exifData, // Include EXIF data from media
|
||||||
caption: media.description, // Use media description as initial caption
|
caption: media.description, // Use media description as initial caption
|
||||||
displayOrder,
|
displayOrder,
|
||||||
status: 'published', // Photos in albums are published by default
|
status: 'published', // Photos in albums are published by default
|
||||||
showInPhotos: true
|
showInPhotos: true
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
media: true // Include media relation in response
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -95,18 +100,8 @@ export const POST: RequestHandler = async (event) => {
|
||||||
mediaId: body.mediaId
|
mediaId: body.mediaId
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return photo with media information for frontend compatibility
|
// Return photo with full media information
|
||||||
const photoWithMedia = {
|
return jsonResponse(photo)
|
||||||
...photo,
|
|
||||||
mediaId: body.mediaId,
|
|
||||||
altText: media.altText,
|
|
||||||
description: media.description,
|
|
||||||
isPhotography: media.isPhotography,
|
|
||||||
mimeType: media.mimeType,
|
|
||||||
size: media.size
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(photoWithMedia)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to add photo to album', error as Error)
|
logger.error('Failed to add photo to album', error as Error)
|
||||||
return errorResponse('Failed to add photo to album', 500)
|
return errorResponse('Failed to add photo to album', 500)
|
||||||
|
|
@ -207,6 +202,9 @@ export const DELETE: RequestHandler = async (event) => {
|
||||||
where: {
|
where: {
|
||||||
id: photoIdNum,
|
id: photoIdNum,
|
||||||
albumId: albumId // Ensure photo belongs to this album
|
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)
|
return errorResponse('Photo not found in this album', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and remove the specific media usage record for this photo
|
// Remove media usage record if photo has a mediaId
|
||||||
// We need to find the media ID associated with this photo to remove the correct usage record
|
if (photo.mediaId) {
|
||||||
const mediaUsage = await prisma.mediaUsage.findFirst({
|
await prisma.mediaUsage.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
mediaId: photo.mediaId,
|
||||||
contentType: 'album',
|
contentType: 'album',
|
||||||
contentId: albumId,
|
contentId: albumId,
|
||||||
fieldName: 'photos',
|
fieldName: 'photos'
|
||||||
media: {
|
|
||||||
filename: photo.filename // Match by filename since that's how they're linked
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (mediaUsage) {
|
|
||||||
await prisma.mediaUsage.delete({
|
|
||||||
where: { id: mediaUsage.id }
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,18 @@ export const GET: RequestHandler = async (event) => {
|
||||||
width: true,
|
width: true,
|
||||||
height: true,
|
height: true,
|
||||||
caption: true,
|
caption: true,
|
||||||
displayOrder: true
|
displayOrder: true,
|
||||||
|
mediaId: true,
|
||||||
|
media: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
altText: true,
|
||||||
|
description: true,
|
||||||
|
isPhotography: true,
|
||||||
|
mimeType: true,
|
||||||
|
size: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,10 @@ export const GET: RequestHandler = async (event) => {
|
||||||
height: true,
|
height: true,
|
||||||
caption: true,
|
caption: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true
|
description: true,
|
||||||
|
createdAt: true,
|
||||||
|
publishedAt: true,
|
||||||
|
exifData: true
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: offset,
|
skip: offset,
|
||||||
|
|
@ -92,22 +95,38 @@ export const GET: RequestHandler = async (event) => {
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Transform individual photos to Photo format
|
// Transform individual photos to Photo format
|
||||||
const photos: Photo[] = individualPhotos.map((photo) => ({
|
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}`,
|
id: `photo-${photo.id}`,
|
||||||
src: photo.url,
|
src: photo.url,
|
||||||
alt: photo.title || photo.caption || photo.filename,
|
alt: photo.title || photo.caption || photo.filename,
|
||||||
caption: photo.caption || undefined,
|
caption: photo.caption || undefined,
|
||||||
width: photo.width || 400,
|
width: photo.width || 400,
|
||||||
height: photo.height || 400
|
height: photo.height || 400,
|
||||||
}))
|
createdAt: photoDate
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Combine albums and individual photos
|
// Combine albums and individual photos
|
||||||
const photoItems: PhotoItem[] = [...photoAlbums, ...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) => {
|
photoItems.sort((a, b) => {
|
||||||
const dateA = 'createdAt' in a ? new Date(a.createdAt) : new Date()
|
const dateA = a.createdAt ? new Date(a.createdAt) : new Date()
|
||||||
const dateB = 'createdAt' in b ? new Date(b.createdAt) : new Date()
|
const dateB = b.createdAt ? new Date(b.createdAt) : new Date()
|
||||||
return dateB.getTime() - dateA.getTime()
|
return dateB.getTime() - dateA.getTime()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ export const GET: RequestHandler = async (event) => {
|
||||||
include: {
|
include: {
|
||||||
album: {
|
album: {
|
||||||
select: { id: true, title: true, slug: true }
|
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)
|
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)
|
return jsonResponse(photo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to retrieve photo', error as 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()
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
const type = $derived(data.type)
|
||||||
const album = $derived(data.album)
|
const album = $derived(data.album)
|
||||||
|
const photo = $derived(data.photo)
|
||||||
const error = $derived(data.error)
|
const error = $derived(data.error)
|
||||||
|
|
||||||
// Transform album data to PhotoItem format for PhotoGrid
|
// Transform album data to PhotoItem format for PhotoGrid
|
||||||
|
|
@ -35,7 +37,7 @@
|
||||||
|
|
||||||
// Generate metadata
|
// Generate metadata
|
||||||
const metaTags = $derived(
|
const metaTags = $derived(
|
||||||
album
|
type === 'album' && album
|
||||||
? generateMetaTags({
|
? generateMetaTags({
|
||||||
title: album.title,
|
title: album.title,
|
||||||
description:
|
description:
|
||||||
|
|
@ -45,9 +47,17 @@
|
||||||
image: album.photos?.[0]?.url,
|
image: album.photos?.[0]?.url,
|
||||||
titleFormat: { type: 'by' }
|
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({
|
: generateMetaTags({
|
||||||
title: 'Album Not Found',
|
title: 'Not Found',
|
||||||
description: 'The album you are looking for could not be found.',
|
description: 'The content you are looking for could not be found.',
|
||||||
url: pageUrl,
|
url: pageUrl,
|
||||||
noindex: true
|
noindex: true
|
||||||
})
|
})
|
||||||
|
|
@ -55,7 +65,7 @@
|
||||||
|
|
||||||
// Generate image gallery JSON-LD
|
// Generate image gallery JSON-LD
|
||||||
const galleryJsonLd = $derived(
|
const galleryJsonLd = $derived(
|
||||||
album
|
type === 'album' && album
|
||||||
? generateImageGalleryJsonLd({
|
? generateImageGalleryJsonLd({
|
||||||
name: album.title,
|
name: album.title,
|
||||||
description: album.description,
|
description: album.description,
|
||||||
|
|
@ -66,6 +76,15 @@
|
||||||
caption: photo.caption
|
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
|
: null
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -96,12 +115,12 @@
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-message">
|
<div class="error-message">
|
||||||
<h1>Album Not Found</h1>
|
<h1>Not Found</h1>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<BackButton href="/photos" label="Back to Photos" />
|
<BackButton href="/photos" label="Back to Photos" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if album}
|
{:else if type === 'album' && album}
|
||||||
<div class="album-page">
|
<div class="album-page">
|
||||||
<!-- Album Card -->
|
<!-- Album Card -->
|
||||||
<div class="album-card">
|
<div class="album-card">
|
||||||
|
|
@ -133,6 +152,36 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -240,4 +289,55 @@
|
||||||
padding: $unit-6x $unit-3x;
|
padding: $unit-6x $unit-3x;
|
||||||
color: $grey-40;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,30 +2,41 @@ import type { PageLoad } from './$types'
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, fetch }) => {
|
export const load: PageLoad = async ({ params, fetch }) => {
|
||||||
try {
|
try {
|
||||||
// Fetch the specific album using the individual album endpoint which includes photos
|
// First try to fetch as an album
|
||||||
const response = await fetch(`/api/albums/by-slug/${params.slug}`)
|
const albumResponse = await fetch(`/api/albums/by-slug/${params.slug}`)
|
||||||
if (!response.ok) {
|
if (albumResponse.ok) {
|
||||||
if (response.status === 404) {
|
const album = await albumResponse.json()
|
||||||
throw new Error('Album not found')
|
|
||||||
}
|
|
||||||
throw new Error('Failed to fetch album')
|
|
||||||
}
|
|
||||||
|
|
||||||
const album = await response.json()
|
|
||||||
|
|
||||||
// Check if this is a photography album and published
|
// Check if this is a photography album and published
|
||||||
if (!album.isPhotography || album.status !== 'published') {
|
if (album.isPhotography && album.status === 'published') {
|
||||||
throw new Error('Album not found')
|
return {
|
||||||
|
type: 'album' as const,
|
||||||
|
album,
|
||||||
|
photo: null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If not found as album or not a photography album, try as individual photo
|
||||||
|
const photoResponse = await fetch(`/api/photos/by-slug/${params.slug}`)
|
||||||
|
if (photoResponse.ok) {
|
||||||
|
const photo = await photoResponse.json()
|
||||||
return {
|
return {
|
||||||
album
|
type: 'photo' as const,
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading album:', error)
|
|
||||||
return {
|
|
||||||
album: null,
|
album: null,
|
||||||
error: error instanceof Error ? error.message : 'Failed to load album'
|
photo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither album nor photo found
|
||||||
|
throw new Error('Content not found')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading content:', error)
|
||||||
|
return {
|
||||||
|
type: null,
|
||||||
|
album: null,
|
||||||
|
photo: null,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to load content'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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