From 610a4212078d8f2913863573a1c42580685280a2 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 13 Jun 2025 03:47:52 -0400 Subject: [PATCH] Better single photo view --- prd/PRD-dominant-color-extraction.md | 187 +++++ .../migration.sql | 8 + prisma/schema.prisma | 4 + scripts/check-photos-display.ts | 113 +++ scripts/debug-photos.md | 42 ++ scripts/test-media-sharing.ts | 199 ++++++ scripts/test-photos-query.ts | 106 +++ src/lib/admin-auth.ts | 27 +- src/lib/components/PhotoItem.svelte | 5 +- .../components/admin/GalleryUploader.svelte | 311 +++++--- .../components/admin/MediaDetailsModal.svelte | 625 +++++++++------- src/lib/components/admin/Modal.svelte | 8 +- src/lib/types/photos.ts | 1 + src/routes/admin/albums/+page.svelte | 6 +- .../admin/albums/[id]/edit/+page.svelte | 83 ++- src/routes/api/albums/[id]/+server.ts | 56 +- src/routes/api/albums/[id]/photos/+server.ts | 47 +- .../api/albums/by-slug/[slug]/+server.ts | 13 +- src/routes/api/photos/+server.ts | 43 +- src/routes/api/photos/[id]/+server.ts | 21 +- src/routes/api/test-photos/+server.ts | 241 +++++++ src/routes/photos/[slug]/+page.svelte | 112 ++- src/routes/photos/[slug]/+page.ts | 43 +- src/routes/photos/p/[id]/+page.svelte | 665 ++++++++++++++++++ src/routes/photos/p/[id]/+page.ts | 42 ++ 25 files changed, 2540 insertions(+), 468 deletions(-) create mode 100644 prd/PRD-dominant-color-extraction.md create mode 100644 prisma/migrations/20250612104142_add_media_reference_to_photo/migration.sql create mode 100644 scripts/check-photos-display.ts create mode 100644 scripts/debug-photos.md create mode 100755 scripts/test-media-sharing.ts create mode 100644 scripts/test-photos-query.ts create mode 100644 src/routes/api/test-photos/+server.ts create mode 100644 src/routes/photos/p/[id]/+page.svelte create mode 100644 src/routes/photos/p/[id]/+page.ts diff --git a/prd/PRD-dominant-color-extraction.md b/prd/PRD-dominant-color-extraction.md new file mode 100644 index 0000000..cef28c5 --- /dev/null +++ b/prd/PRD-dominant-color-extraction.md @@ -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 \ No newline at end of file diff --git a/prisma/migrations/20250612104142_add_media_reference_to_photo/migration.sql b/prisma/migrations/20250612104142_add_media_reference_to_photo/migration.sql new file mode 100644 index 0000000..d53f996 --- /dev/null +++ b/prisma/migrations/20250612104142_add_media_reference_to_photo/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cfa7d1f..c36028e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,6 +86,7 @@ model Album { model Photo { id Int @id @default(autoincrement()) albumId Int? + mediaId Int? // Reference to the Media item filename String @db.VarChar(255) url String @db.VarChar(500) thumbnailUrl String? @db.VarChar(500) @@ -107,9 +108,11 @@ model Photo { // Relations album Album? @relation(fields: [albumId], references: [id], onDelete: Cascade) + media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull) @@index([slug]) @@index([status]) + @@index([mediaId]) } // Media table (general uploads) @@ -133,6 +136,7 @@ model Media { // Relations usage MediaUsage[] + photos Photo[] } // Media usage tracking table diff --git a/scripts/check-photos-display.ts b/scripts/check-photos-display.ts new file mode 100644 index 0000000..8c5c519 --- /dev/null +++ b/scripts/check-photos-display.ts @@ -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) + + 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) + + 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() \ No newline at end of file diff --git a/scripts/debug-photos.md b/scripts/debug-photos.md new file mode 100644 index 0000000..83693e2 --- /dev/null +++ b/scripts/debug-photos.md @@ -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) \ No newline at end of file diff --git a/scripts/test-media-sharing.ts b/scripts/test-media-sharing.ts new file mode 100755 index 0000000..93f2d67 --- /dev/null +++ b/scripts/test-media-sharing.ts @@ -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() \ No newline at end of file diff --git a/scripts/test-photos-query.ts b/scripts/test-photos-query.ts new file mode 100644 index 0000000..b713405 --- /dev/null +++ b/scripts/test-photos-query.ts @@ -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() \ No newline at end of file diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts index a89ff9b..c33ccf2 100644 --- a/src/lib/admin-auth.ts +++ b/src/lib/admin-auth.ts @@ -10,25 +10,40 @@ export function setAdminAuth(username: string, password: string) { // Get auth headers for API requests export function getAuthHeaders(): HeadersInit { - if (!adminCredentials) { - // For development, use default credentials - // In production, this should redirect to login - adminCredentials = btoa('admin:localdev') + // First try to get from localStorage (where login stores it) + const storedAuth = typeof window !== 'undefined' ? localStorage.getItem('admin_auth') : null + if (storedAuth) { + return { + Authorization: `Basic ${storedAuth}` + } } + // Fall back to in-memory credentials if set + if (adminCredentials) { + return { + Authorization: `Basic ${adminCredentials}` + } + } + + // Development fallback + const fallbackAuth = btoa('admin:localdev') return { - Authorization: `Basic ${adminCredentials}` + Authorization: `Basic ${fallbackAuth}` } } // Check if user is authenticated (basic check) export function isAuthenticated(): boolean { - return adminCredentials !== null + const storedAuth = typeof window !== 'undefined' ? localStorage.getItem('admin_auth') : null + return storedAuth !== null || adminCredentials !== null } // Clear auth (logout) export function clearAuth() { adminCredentials = null + if (typeof window !== 'undefined') { + localStorage.removeItem('admin_auth') + } } // Make authenticated API request diff --git a/src/lib/components/PhotoItem.svelte b/src/lib/components/PhotoItem.svelte index 604ecf7..f714439 100644 --- a/src/lib/components/PhotoItem.svelte +++ b/src/lib/components/PhotoItem.svelte @@ -24,8 +24,9 @@ const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix goto(`/photos/${albumSlug}/${photoId}`) } else { - // For standalone photos, navigate to a generic photo page (to be implemented) - console.log('Individual photo navigation not yet implemented') + // Navigate to individual photo page using the photo ID + const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix + goto(`/photos/p/${photoId}`) } } } diff --git a/src/lib/components/admin/GalleryUploader.svelte b/src/lib/components/admin/GalleryUploader.svelte index 10477b2..5779089 100644 --- a/src/lib/components/admin/GalleryUploader.svelte +++ b/src/lib/components/admin/GalleryUploader.svelte @@ -4,6 +4,7 @@ import Input from './Input.svelte' import SmartImage from '../SmartImage.svelte' import MediaLibraryModal from './MediaLibraryModal.svelte' + import MediaDetailsModal from './MediaDetailsModal.svelte' import { authenticatedFetch } from '$lib/admin-auth' interface Props { @@ -20,6 +21,7 @@ helpText?: string showBrowseLibrary?: boolean maxFileSize?: number // MB limit + disabled?: boolean } let { @@ -35,7 +37,8 @@ placeholder = 'Drag and drop images here, or click to browse', helpText, showBrowseLibrary = false, - maxFileSize = 10 + maxFileSize = 10, + disabled = false }: Props = $props() // State @@ -47,6 +50,8 @@ let draggedIndex = $state(null) let draggedOverIndex = $state(null) let isMediaLibraryOpen = $state(false) + let isImageModalOpen = $state(false) + let selectedImage = $state(null) // Computed properties const hasImages = $derived(value && value.length > 0) @@ -93,7 +98,7 @@ // Handle file selection/drop async function handleFiles(files: FileList) { - if (files.length === 0) return + if (files.length === 0 || disabled) return // Validate files const filesToUpload: File[] = [] @@ -150,8 +155,13 @@ // Brief delay to show completion setTimeout(() => { - const newValue = [...(value || []), ...uploadedMedia] - value = newValue + console.log('[GalleryUploader] Upload completed:', { + uploadedCount: uploadedMedia.length, + uploaded: uploadedMedia.map(m => ({ id: m.id, filename: m.filename })), + currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename })) + }) + + // Don't update value here - let parent handle it through API response // Only pass the newly uploaded media, not the entire gallery onUpload(uploadedMedia) isUploading = false @@ -214,53 +224,26 @@ uploadError = null } - // Update alt text on server - async function handleAltTextChange(item: any, newAltText: string) { - if (!item) return - - try { - // For album photos, use mediaId; for direct media objects, use id - const mediaId = item.mediaId || item.id - if (!mediaId) { - console.error('No media ID found for alt text update') - return - } - - const response = await authenticatedFetch(`/api/media/${mediaId}/metadata`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - altText: newAltText.trim() || null - }) - }) - - if (response.ok) { - const updatedData = await response.json() - if (value) { - const index = value.findIndex((v) => (v.mediaId || v.id) === mediaId) - if (index !== -1) { - value[index] = { - ...value[index], - altText: updatedData.altText, - updatedAt: updatedData.updatedAt - } - value = [...value] - } - } - } - } catch (error) { - console.error('Failed to update alt text:', error) - } - } // Drag and drop reordering handlers function handleImageDragStart(event: DragEvent, index: number) { + // Prevent reordering while uploading or disabled + if (isUploading || disabled) { + event.preventDefault() + return + } + draggedIndex = index if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move' } + + // Debug logging + console.log('[GalleryUploader] Drag start:', { + index, + item: value[index], + totalItems: value.length + }) } function handleImageDragOver(event: DragEvent, index: number) { @@ -278,7 +261,20 @@ function handleImageDrop(event: DragEvent, dropIndex: number) { event.preventDefault() - if (draggedIndex === null || !value) return + if (draggedIndex === null || !value || isUploading || disabled) return + + // Debug logging before reorder + console.log('[GalleryUploader] Before reorder:', { + draggedIndex, + dropIndex, + totalItems: value.length, + items: value.map((v, i) => ({ + index: i, + id: v.id, + mediaId: v.mediaId, + filename: v.filename + })) + }) const newValue = [...value] const draggedItem = newValue[draggedIndex] @@ -290,6 +286,17 @@ const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex newValue.splice(adjustedDropIndex, 0, draggedItem) + // Debug logging after reorder + console.log('[GalleryUploader] After reorder:', { + adjustedDropIndex, + newItems: newValue.map((v, i) => ({ + index: i, + id: v.id, + mediaId: v.mediaId, + filename: v.filename + })) + }) + value = newValue onUpload(newValue) if (onReorder) { @@ -314,6 +321,13 @@ // For gallery mode, selectedMedia will be an array const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia] + // Debug logging + console.log('[GalleryUploader] Media selected from library:', { + selectedCount: mediaArray.length, + selected: mediaArray.map(m => ({ id: m.id, filename: m.filename })), + currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename })) + }) + // Filter out duplicates before passing to parent // Create a comprehensive set of existing IDs (both id and mediaId) const existingIds = new Set() @@ -327,6 +341,11 @@ return !existingIds.has(media.id) && !existingIds.has(media.mediaId) }) + console.log('[GalleryUploader] Filtered new media:', { + newCount: newMedia.length, + newMedia: newMedia.map(m => ({ id: m.id, filename: m.filename })) + }) + if (newMedia.length > 0) { // Don't modify the value array here - let the parent component handle it // through the API calls and then update the bound value @@ -337,6 +356,49 @@ function handleMediaLibraryClose() { isMediaLibraryOpen = false } + + // Handle clicking on an image to open details modal + function handleImageClick(media: any) { + // Convert to Media format if needed + selectedImage = { + id: media.mediaId || media.id, + filename: media.filename, + originalName: media.originalName || media.filename, + mimeType: media.mimeType || 'image/jpeg', + size: media.size || 0, + url: media.url, + thumbnailUrl: media.thumbnailUrl, + width: media.width, + height: media.height, + altText: media.altText || '', + description: media.description || '', + isPhotography: media.isPhotography || false, + createdAt: media.createdAt, + updatedAt: media.updatedAt, + exifData: media.exifData || null, + usedIn: media.usedIn || [] + } + isImageModalOpen = true + } + + // Handle updates from the media details modal + function handleImageUpdate(updatedMedia: any) { + // Update the media in our value array + const index = value.findIndex(m => (m.mediaId || m.id) === updatedMedia.id) + if (index !== -1) { + value[index] = { + ...value[index], + altText: updatedMedia.altText, + description: updatedMedia.description, + isPhotography: updatedMedia.isPhotography, + updatedAt: updatedMedia.updatedAt + } + value = [...value] // Trigger reactivity + } + + // Update selectedImage for the modal + selectedImage = updatedMedia + }