diff --git a/src/lib/components/admin/GalleryUploader.svelte b/src/lib/components/admin/GalleryUploader.svelte index c014728..6f320ee 100644 --- a/src/lib/components/admin/GalleryUploader.svelte +++ b/src/lib/components/admin/GalleryUploader.svelte @@ -9,9 +9,10 @@ interface Props { label: string - value?: Media[] - onUpload: (media: Media[]) => void - onReorder?: (media: Media[]) => void + value?: any[] // Changed from Media[] to any[] to be more flexible + onUpload: (media: any[]) => void + onReorder?: (media: any[]) => void + onRemove?: (item: any, index: number) => void // New callback for removals maxItems?: number allowAltText?: boolean required?: boolean @@ -27,6 +28,7 @@ value = $bindable([]), onUpload, onReorder, + onRemove, maxItems = 20, allowAltText = true, required = false, @@ -151,7 +153,8 @@ setTimeout(() => { const newValue = [...(value || []), ...uploadedMedia] value = newValue - onUpload(newValue) + // Only pass the newly uploaded media, not the entire gallery + onUpload(uploadedMedia) isUploading = false uploadProgress = {} }, 500) @@ -196,21 +199,36 @@ } } - // Remove individual image + // Remove individual image - now passes the item to be removed instead of doing it locally function handleRemoveImage(index: number) { - if (!value) return - const newValue = value.filter((_, i) => i !== index) - value = newValue - onUpload(newValue) + if (!value || !value[index]) return + + const itemToRemove = value[index] + // Call the onRemove callback if provided, otherwise fall back to onUpload + if (onRemove) { + onRemove(itemToRemove, index) + } else { + // Fallback: remove locally and call onUpload + const newValue = value.filter((_, i) => i !== index) + value = newValue + onUpload(newValue) + } uploadError = null } // Update alt text on server - async function handleAltTextChange(media: Media, newAltText: string) { - if (!media) return + async function handleAltTextChange(item: any, newAltText: string) { + if (!item) return try { - const response = await authenticatedFetch(`/api/media/${media.id}/metadata`, { + // 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' @@ -223,7 +241,7 @@ if (response.ok) { const updatedData = await response.json() if (value) { - const index = value.findIndex(m => m.id === media.id) + 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] @@ -290,18 +308,20 @@ isMediaLibraryOpen = true } - function handleMediaSelect(selectedMedia: Media | Media[]) { + function handleMediaSelect(selectedMedia: any | any[]) { // For gallery mode, selectedMedia will be an array const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia] // Add selected media to existing gallery (avoid duplicates) - const currentIds = value?.map(m => m.id) || [] + // Check both id and mediaId to handle different object types + const currentIds = value?.map(m => m.mediaId || m.id) || [] const newMedia = mediaArray.filter(media => !currentIds.includes(media.id)) if (newMedia.length > 0) { const updatedGallery = [...(value || []), ...newMedia] value = updatedGallery - onUpload(updatedGallery) + // Only pass the newly selected media, not the entire gallery + onUpload(newMedia) } } @@ -445,7 +465,22 @@
([]) let isManagingPhotos = $state(false) - let isUploading = $state(false) - let uploadProgress = $state>({}) - let uploadErrors = $state([]) - let fileInput: HTMLInputElement // Media details modal state let isMediaDetailsOpen = $state(false) @@ -189,14 +185,32 @@ try { isManagingPhotos = true + error = '' // Clear any previous errors + const auth = localStorage.getItem('admin_auth') if (!auth) { goto('/admin/login') return } + // Check for duplicates before adding + const existingMediaIds = albumPhotos.map((p) => p.mediaId).filter(Boolean) + const newMedia = mediaArray.filter((media) => !existingMediaIds.includes(media.id)) + + if (newMedia.length === 0) { + error = 'All selected photos are already in this album' + return + } + + if (newMedia.length < mediaArray.length) { + console.log( + `Skipping ${mediaArray.length - newMedia.length} photos that are already in the album` + ) + } + // Add photos to album via API - for (const media of mediaArray) { + const addedPhotos = [] + for (const media of newMedia) { const response = await fetch(`/api/albums/${album.id}/photos`, { method: 'POST', headers: { @@ -205,22 +219,28 @@ }, body: JSON.stringify({ mediaId: media.id, - displayOrder: albumPhotos.length + displayOrder: albumPhotos.length + addedPhotos.length }) }) if (!response.ok) { - throw new Error(`Failed to add photo ${media.filename}`) + const errorData = await response.text() + throw new Error(`Failed to add photo ${media.filename}: ${response.status} ${errorData}`) } const photo = await response.json() - albumPhotos = [...albumPhotos, photo] + addedPhotos.push(photo) } + // Update local state with all added photos + albumPhotos = [...albumPhotos, ...addedPhotos] + // Update album photo count if (album._count) { album._count.photos = albumPhotos.length } + + console.log(`Successfully added ${addedPhotos.length} photos to album`) } catch (err) { error = err instanceof Error ? err.message : 'Failed to add photos' console.error('Failed to add photos:', err) @@ -230,38 +250,62 @@ } } - async function handleRemovePhoto(photoId: number) { - if (!confirm('Are you sure you want to remove this photo from the album?')) { - return + async function handleRemovePhoto(photoId: number, skipConfirmation = false) { + const photoToRemove = albumPhotos.find((p) => p.id === photoId) + if (!photoToRemove) { + error = 'Photo not found in album' + return false + } + + if ( + !skipConfirmation && + !confirm( + `Remove "${photoToRemove.filename || 'this photo'}" from this album?\n\nNote: This will only remove it from the album. The original photo will remain in your media library.` + ) + ) { + return false } try { isManagingPhotos = true + error = '' // Clear any previous errors + const auth = localStorage.getItem('admin_auth') if (!auth) { goto('/admin/login') - return + return false } - const response = await fetch(`/api/photos/${photoId}`, { + console.log(`Attempting to remove photo with ID: ${photoId} from album ${album.id}`) + console.log('Photo to remove:', photoToRemove) + + const response = await fetch(`/api/albums/${album.id}/photos?photoId=${photoId}`, { method: 'DELETE', headers: { Authorization: `Basic ${auth}` } }) + console.log(`DELETE response status: ${response.status}`) + if (!response.ok) { - throw new Error('Failed to remove photo from album') + const errorData = await response.text() + console.error(`Delete failed: ${response.status} ${errorData}`) + throw new Error(`Failed to remove photo: ${response.status} ${errorData}`) } - // Remove from local state + // Remove from local state only after successful API call albumPhotos = albumPhotos.filter((photo) => photo.id !== photoId) // Update album photo count if (album._count) { album._count.photos = albumPhotos.length } + + console.log(`Successfully removed photo ${photoId} from album`) + return true } catch (err) { - error = err instanceof Error ? err.message : 'Failed to remove photo' + error = err instanceof Error ? err.message : 'Failed to remove photo from album' console.error('Failed to remove photo:', err) + return false } finally { isManagingPhotos = false } @@ -394,110 +438,56 @@ } } - // Direct upload functions - function handleFileSelect(event: Event) { - const target = event.target as HTMLInputElement - const files = Array.from(target.files || []) - if (files.length > 0) { - uploadFilesToAlbum(files) - } - // Reset input so same files can be selected again - target.value = '' - } - - async function uploadFilesToAlbum(files: File[]) { - if (files.length === 0) return - - isUploading = true - uploadErrors = [] - uploadProgress = {} - - const auth = localStorage.getItem('admin_auth') - if (!auth) { - goto('/admin/login') - return - } - - // Filter for image files - const imageFiles = files.filter((file) => file.type.startsWith('image/')) - - if (imageFiles.length !== files.length) { - uploadErrors = [ - ...uploadErrors, - `${files.length - imageFiles.length} non-image files were skipped` - ] - } - + // Handle new photos added through GalleryUploader (uploads or library selections) + async function handleGalleryAdd(newPhotos: any[]) { try { - // Upload each file and add to album - for (const file of imageFiles) { - try { - // First upload the file to media library - const formData = new FormData() - formData.append('file', file) - - // If this is a photography album, mark the uploaded media as photography - if (isPhotography) { - formData.append('isPhotography', 'true') - } - - const uploadResponse = await fetch('/api/media/upload', { - method: 'POST', - headers: { - Authorization: `Basic ${auth}` - }, - body: formData - }) - - if (!uploadResponse.ok) { - const error = await uploadResponse.json() - uploadErrors = [...uploadErrors, `${file.name}: ${error.message || 'Upload failed'}`] - continue - } - - const media = await uploadResponse.json() - uploadProgress = { ...uploadProgress, [file.name]: 50 } - - // Then add the uploaded media to the album - const addResponse = await fetch(`/api/albums/${album.id}/photos`, { - method: 'POST', - headers: { - Authorization: `Basic ${auth}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - mediaId: media.id, - displayOrder: albumPhotos.length - }) - }) - - if (!addResponse.ok) { - uploadErrors = [...uploadErrors, `${file.name}: Failed to add to album`] - continue - } - - const photo = await addResponse.json() - albumPhotos = [...albumPhotos, photo] - uploadProgress = { ...uploadProgress, [file.name]: 100 } - } catch (err) { - uploadErrors = [...uploadErrors, `${file.name}: Network error`] + if (newPhotos.length > 0) { + // Check if these are new uploads (have File objects) or library selections (have media IDs) + const uploadsToAdd = newPhotos.filter(photo => photo instanceof File || !photo.id) + const libraryPhotosToAdd = newPhotos.filter(photo => photo.id && !(photo instanceof File)) + + // Handle new uploads + if (uploadsToAdd.length > 0) { + await handleAddPhotosFromUpload(uploadsToAdd) + } + + // Handle library selections + if (libraryPhotosToAdd.length > 0) { + await handleAddPhotos(libraryPhotosToAdd) } } - - // Update album photo count - if (album._count) { - album._count.photos = albumPhotos.length - } - } finally { - isUploading = false - // Clear progress after a delay - setTimeout(() => { - uploadProgress = {} - uploadErrors = [] - }, 3000) + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to add photos' + console.error('Failed to add photos:', err) } } + // Handle photo removal from GalleryUploader + async function handleGalleryRemove(itemToRemove: any, index: number) { + try { + // Find the photo ID to remove + const photoId = itemToRemove.id + if (!photoId) { + error = 'Cannot remove photo: no photo ID found' + return + } + + // Call the existing remove photo function + const success = await handleRemovePhoto(photoId, true) // Skip confirmation since user clicked remove + if (!success) { + // If removal failed, we need to reset the gallery state + // Force a reactivity update + albumPhotos = [...albumPhotos] + } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to remove photo' + console.error('Failed to remove photo:', err) + // Reset gallery state on error + albumPhotos = [...albumPhotos] + } + } + + function generateSlug(text: string): string { return text .toLowerCase() @@ -512,7 +502,6 @@ } }) - const canSave = $derived(title.trim().length > 0 && slug.trim().length > 0) @@ -670,70 +659,14 @@
-
-

Photos ({albumPhotos.length})

-
- - -
-
+

Photos ({albumPhotos.length})

0 && slug.trim().length > 0) @@ -115,46 +113,14 @@ /> -

New Album

-
- - - {#if isPublishDropdownOpen} - - {handleSave('draft'); isPublishDropdownOpen = false}}> - Save as Draft - - - {/if} -
+ handleSave('published')} + onSaveDraft={() => handleSave('draft')} + disabled={!canSave || isSaving} + isLoading={isSaving} + />
@@ -165,7 +131,7 @@

Album Details

- + \ No newline at end of file + diff --git a/src/routes/api/albums/[id]/+server.ts b/src/routes/api/albums/[id]/+server.ts index 551915c..91e75e3 100644 --- a/src/routes/api/albums/[id]/+server.ts +++ b/src/routes/api/albums/[id]/+server.ts @@ -47,19 +47,20 @@ export const GET: RequestHandler = async (event) => { } }) - // Enrich photos with media information + // Enrich photos with media information using proper media usage tracking const photosWithMedia = album.photos.map(photo => { - // Try to find matching media by filename since we don't have direct relationship - const media = Array.from(mediaMap.values()).find(m => m.filename === photo.filename) + // Find the corresponding media usage record for this photo + const usage = mediaUsages.find(u => u.media && u.media.filename === photo.filename) + const media = usage?.media return { ...photo, - mediaId: media?.id, - altText: media?.altText, - description: media?.description, - isPhotography: media?.isPhotography, - mimeType: media?.mimeType, - size: media?.size + mediaId: media?.id || null, + altText: media?.altText || '', + description: media?.description || photo.caption || '', + isPhotography: media?.isPhotography || false, + mimeType: media?.mimeType || 'image/jpeg', + size: media?.size || 0 } }) diff --git a/src/routes/api/albums/[id]/photos/+server.ts b/src/routes/api/albums/[id]/photos/+server.ts index e78e12d..f971627 100644 --- a/src/routes/api/albums/[id]/photos/+server.ts +++ b/src/routes/api/albums/[id]/photos/+server.ts @@ -161,4 +161,89 @@ export const PUT: RequestHandler = async (event) => { logger.error('Failed to update photo order', error as Error) return errorResponse('Failed to update photo order', 500) } +} + +// DELETE /api/albums/[id]/photos - Remove a photo from an album (without deleting the media) +export const DELETE: RequestHandler = async (event) => { + // Check authentication + if (!checkAdminAuth(event)) { + return errorResponse('Unauthorized', 401) + } + + const albumId = parseInt(event.params.id) + if (isNaN(albumId)) { + return errorResponse('Invalid album ID', 400) + } + + try { + const url = new URL(event.request.url) + const photoId = url.searchParams.get('photoId') + + logger.info('DELETE photo request', { albumId, photoId }) + + if (!photoId || isNaN(parseInt(photoId))) { + return errorResponse('Photo ID is required as query parameter', 400) + } + + const photoIdNum = parseInt(photoId) + + // Check if album exists + const album = await prisma.album.findUnique({ + where: { id: albumId } + }) + + if (!album) { + logger.error('Album not found', { albumId }) + return errorResponse('Album not found', 404) + } + + // Check if photo exists in this album + const photo = await prisma.photo.findFirst({ + where: { + id: photoIdNum, + albumId: albumId // Ensure photo belongs to this album + } + }) + + logger.info('Photo lookup result', { photoIdNum, albumId, found: !!photo }) + + if (!photo) { + logger.error('Photo not found in album', { photoIdNum, albumId }) + return errorResponse('Photo not found in this album', 404) + } + + // Find and remove the specific media usage record for this photo + // We need to find the media ID associated with this photo to remove the correct usage record + const mediaUsage = await prisma.mediaUsage.findFirst({ + where: { + contentType: 'album', + contentId: albumId, + fieldName: 'photos', + media: { + filename: photo.filename // Match by filename since that's how they're linked + } + } + }) + + if (mediaUsage) { + await prisma.mediaUsage.delete({ + where: { id: mediaUsage.id } + }) + } + + // Delete the photo record (this removes it from the album but keeps the media) + await prisma.photo.delete({ + where: { id: photoIdNum } + }) + + logger.info('Photo removed from album', { + photoId: photoIdNum, + albumId: albumId + }) + + return new Response(null, { status: 204 }) + } catch (error) { + logger.error('Failed to remove photo from album', error as Error) + return errorResponse('Failed to remove photo from album', 500) + } } \ No newline at end of file diff --git a/src/routes/api/photos/+server.ts b/src/routes/api/photos/+server.ts index ff62950..92b7334 100644 --- a/src/routes/api/photos/+server.ts +++ b/src/routes/api/photos/+server.ts @@ -20,8 +20,7 @@ export const GET: RequestHandler = async (event) => { include: { photos: { where: { - status: 'published', - showInPhotos: true + status: 'published' }, orderBy: { displayOrder: 'asc' }, select: { diff --git a/src/routes/api/photos/[id]/+server.ts b/src/routes/api/photos/[id]/+server.ts index 45ebf4a..23a6980 100644 --- a/src/routes/api/photos/[id]/+server.ts +++ b/src/routes/api/photos/[id]/+server.ts @@ -31,7 +31,8 @@ export const GET: RequestHandler = async (event) => { } } -// DELETE /api/photos/[id] - Delete a photo (remove from album) +// DELETE /api/photos/[id] - Delete a photo completely (removes photo record and media usage) +// NOTE: This deletes the photo entirely. Use DELETE /api/albums/[id]/photos to remove from album only. export const DELETE: RequestHandler = async (event) => { // Check authentication if (!checkAdminAuth(event)) {