Fix adding/removing to albums

This commit is contained in:
Justin Edmund 2025-06-02 07:23:40 -07:00
parent b2ad9efd9c
commit 9f7b408bc7
7 changed files with 267 additions and 262 deletions

View file

@ -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 @@
<!-- Image Preview -->
<div class="image-preview">
<SmartImage
{media}
media={{
id: media.mediaId || media.id,
filename: media.filename,
originalName: media.originalName || media.filename,
mimeType: media.mimeType || 'image/jpeg',
size: media.size || 0,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
altText: media.altText,
description: media.description,
isPhotography: media.isPhotography || false,
createdAt: media.createdAt,
updatedAt: media.updatedAt
}}
alt={media.altText || media.filename || 'Gallery image'}
containerWidth={300}
loading="lazy"

View file

@ -34,10 +34,6 @@
let isMediaLibraryOpen = $state(false)
let albumPhotos = $state<any[]>([])
let isManagingPhotos = $state(false)
let isUploading = $state(false)
let uploadProgress = $state<Record<string, number>>({})
let uploadErrors = $state<string[]>([])
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)
</script>
@ -670,70 +659,14 @@
<!-- Photo Management -->
<div class="form-section">
<div class="section-header">
<h2>Photos ({albumPhotos.length})</h2>
<div class="photo-actions">
<Button
variant="secondary"
onclick={() => fileInput.click()}
disabled={isManagingPhotos || isUploading}
>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 15V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V15M17 8L12 3M12 3L7 8M12 3V15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{isUploading ? 'Uploading...' : 'Upload from Computer'}
</Button>
<Button
variant="secondary"
onclick={() => (isMediaLibraryOpen = true)}
disabled={isManagingPhotos || isUploading}
>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 21L15 15L21 21ZM3 9C3 8.17157 3.67157 7.5 4.5 7.5H19.5C20.3284 7.5 21 8.17157 21 9V18C21 18.8284 20.3284 19.5 19.5 19.5H4.5C3.67157 19.5 3 18.8284 3 18V9Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 13.5L12 10.5L15 13.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Add from Library
</Button>
</div>
</div>
<h2>Photos ({albumPhotos.length})</h2>
<GalleryUploader
label="Album Photos"
bind:value={albumPhotos}
onUpload={handleAddPhotosFromUpload}
onUpload={handleGalleryAdd}
onReorder={handlePhotoReorder}
onRemove={handleGalleryRemove}
showBrowseLibrary={true}
placeholder="Add photos to this album by uploading or selecting from your media library"
helpText="Drag photos to reorder them. Click on photos to edit metadata."
@ -811,7 +744,6 @@
gap: $unit-2x;
}
.btn-icon {
width: 40px;
height: 40px;
@ -872,7 +804,6 @@
font-weight: 600;
margin: 0;
color: $grey-10;
border-bottom: 1px solid $grey-85;
padding-bottom: $unit-2x;
}
@ -1141,18 +1072,6 @@
}
}
// Photo actions styles
.photo-actions {
display: flex;
gap: $unit-2x;
}
.empty-actions {
display: flex;
gap: $unit-2x;
justify-content: center;
margin-top: $unit-3x;
}
// Upload status styles
.upload-status {

View file

@ -69,7 +69,7 @@
const response = await fetch('/api/albums', {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(albumData)
@ -81,10 +81,9 @@
}
const album = await response.json()
// Redirect to album edit page or albums list
goto(`/admin/albums/${album.id}/edit`)
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create album'
console.error('Failed to create album:', err)
@ -97,7 +96,6 @@
goto('/admin/albums')
}
const canSave = $derived(title.trim().length > 0 && slug.trim().length > 0)
</script>
@ -115,46 +113,14 @@
/>
</svg>
</button>
<h1>New Album</h1>
</div>
<div class="header-actions">
<div class="publish-dropdown">
<Button
variant="primary"
buttonSize="large"
onclick={() => handleSave('published')}
disabled={!canSave || isSaving}
>
{isSaving ? 'Publishing...' : 'Publish'}
</Button>
<Button
variant="ghost"
iconOnly
buttonSize="large"
onclick={(e) => {
e.stopPropagation()
isPublishDropdownOpen = !isPublishDropdownOpen
}}
disabled={isSaving}
>
<svg slot="icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{#if isPublishDropdownOpen}
<DropdownMenuContainer>
<DropdownItem onclick={() => {handleSave('draft'); isPublishDropdownOpen = false}}>
Save as Draft
</DropdownItem>
</DropdownMenuContainer>
{/if}
</div>
<PublishDropdown
onPublish={() => handleSave('published')}
onSaveDraft={() => handleSave('draft')}
disabled={!canSave || isSaving}
isLoading={isSaving}
/>
</div>
</header>
@ -165,7 +131,7 @@
<div class="form-section">
<h2>Album Details</h2>
<Input
label="Title"
bind:value={title}
@ -329,7 +295,6 @@
font-weight: 600;
margin: 0;
color: $grey-10;
border-bottom: 1px solid $grey-85;
padding-bottom: $unit-2x;
}
}
@ -414,4 +379,4 @@
}
}
}
</style>
</style>

View file

@ -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
}
})

View file

@ -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)
}
}

View file

@ -20,8 +20,7 @@ export const GET: RequestHandler = async (event) => {
include: {
photos: {
where: {
status: 'published',
showInPhotos: true
status: 'published'
},
orderBy: { displayOrder: 'asc' },
select: {

View file

@ -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)) {