refactor: Extract shared media upload logic into reusable helpers

- Add validateImageFile() helper to mediaHelpers.ts for file validation
- Add uploadMediaFiles() function to centralize /api/media/upload logic
- Add SerializedMedia type to handle JSON response dates correctly
- Replace inline upload icon SVGs with FileIcon component
- Refactor GalleryUploader to use shared validation and upload helpers
- Refactor ImageUploader to use shared validation and upload helpers

This reduces code duplication by ~165 lines and makes upload behavior
consistent across both components.

Co-Authored-By: Justin Edmund <justin@jedmund.com>
This commit is contained in:
Devin AI 2025-11-24 16:19:26 +00:00
parent 3ec59dc996
commit d128c95662
3 changed files with 99 additions and 165 deletions

View file

@ -4,6 +4,8 @@
import SmartImage from '../SmartImage.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import MediaDetailsModal from './MediaDetailsModal.svelte'
import FileIcon from '$icons/FileIcon.svelte'
import { validateImageFile, uploadMediaFiles } from '$lib/utils/mediaHelpers'
// Gallery items can be either Media objects or objects with a mediaId reference
type GalleryItem = Media | (Partial<Media> & { mediaId?: number })
@ -55,43 +57,9 @@
const canAddMore = $derived(!maxItems || !value || value.length < maxItems)
const remainingSlots = $derived(maxItems ? maxItems - (value?.length || 0) : Infinity)
// File validation
// File validation using shared helper
function validateFile(file: File): string | null {
// Check file type
if (!file.type.startsWith('image/')) {
return 'Please select image files only'
}
// Check file size
const sizeMB = file.size / 1024 / 1024
if (sizeMB > maxFileSize) {
return `File size must be less than ${maxFileSize}MB`
}
return null
}
// Upload multiple files to server
async function uploadFiles(files: File[]): Promise<Media[]> {
const uploadPromises = files.map(async (file) => {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/media/upload', {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `Upload failed for ${file.name}`)
}
return await response.json()
})
return Promise.all(uploadPromises)
return validateImageFile(file, maxFileSize)
}
// Handle file selection/drop
@ -140,7 +108,8 @@
}, 100)
})
const uploadedMedia = await uploadFiles(filesToUpload)
// Upload files using shared helper
const uploadedMedia = await uploadMediaFiles(filesToUpload) as Media[]
// Clear progress intervals
progressIntervals.forEach((interval) => clearInterval(interval))
@ -459,54 +428,7 @@
{:else}
<!-- Upload Prompt -->
<div class="upload-prompt">
<svg
class="upload-icon"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="13"
x2="8"
y2="13"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<polyline
points="10,9 9,9 8,9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<FileIcon size={48} class="upload-icon" />
<p class="upload-main-text">{placeholder}</p>
<p class="upload-sub-text">
Supports JPG, PNG, GIF up to {maxFileSize}MB

View file

@ -5,6 +5,8 @@
import SmartImage from '../SmartImage.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import RefreshIcon from '$icons/refresh.svg?component'
import FileIcon from '$icons/FileIcon.svelte'
import { validateImageFile, uploadMediaFiles } from '$lib/utils/mediaHelpers'
interface Props {
label: string
@ -56,45 +58,22 @@
return `aspect-ratio: ${w}/${h}; padding-bottom: ${ratio}%;`
})
// File validation
// File validation using shared helper
function validateFile(file: File): string | null {
// Check file type
if (!file.type.startsWith('image/')) {
return 'Please select an image file'
return validateImageFile(file, maxFileSize)
}
// Check file size
const sizeMB = file.size / 1024 / 1024
if (sizeMB > maxFileSize) {
return `File size must be less than ${maxFileSize}MB`
}
return null
}
// Upload file to server
// Upload file to server using shared helper
async function uploadFile(file: File): Promise<Media> {
const formData = new FormData()
formData.append('file', file)
// Removed altText upload - description is handled separately
const extraFields: Record<string, string> = {}
// Add description if provided
if (descriptionValue.trim()) {
formData.append('description', descriptionValue.trim())
extraFields.description = descriptionValue.trim()
}
const response = await fetch('/api/media/upload', {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Upload failed')
}
return await response.json()
const uploadedMedia = await uploadMediaFiles([file], { extraFields })
return uploadedMedia[0] as Media
}
// Handle file selection/drop
@ -420,54 +399,7 @@
{:else}
<!-- Upload Prompt -->
<div class="upload-prompt">
<svg
class="upload-icon"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="13"
x2="8"
y2="13"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<polyline
points="10,9 9,9 8,9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<FileIcon size={48} class="upload-icon" />
<p class="upload-main-text">{placeholder}</p>
<p class="upload-sub-text">
Supports JPG, PNG, GIF up to {maxFileSize}MB

View file

@ -73,6 +73,25 @@ export function validateFileType(file: File, acceptedTypes: string[]): boolean {
})
}
/**
* Validate image file for upload (type and size)
* Returns null if valid, error message if invalid
*/
export function validateImageFile(file: File, maxSizeMB: number): string | null {
// Check file type
if (!file.type.startsWith('image/')) {
return 'Please select an image file'
}
// Check file size
const sizeMB = file.size / 1024 / 1024
if (sizeMB > maxSizeMB) {
return `File size must be less than ${maxSizeMB}MB`
}
return null
}
/**
* Get display name for MIME type
*/
@ -115,3 +134,64 @@ export function formatBitrate(bitrate: number): string {
if (bitrate < 1000000) return `${(bitrate / 1000).toFixed(0)} kbps`
return `${(bitrate / 1000000).toFixed(1)} Mbps`
}
/**
* Serialized Media type - represents Media as returned from API (dates as strings)
*/
export interface SerializedMedia {
id: number
filename: string
originalName: string
mimeType: string
size: number
url: string
thumbnailUrl: string | null
width: number | null
height: number | null
description: string | null
isPhotography: boolean
createdAt: string
updatedAt: string
exifData: Record<string, unknown> | null
usedIn: string[]
}
/**
* Upload media files to the server
* Returns serialized media objects (with string dates from JSON)
*/
export async function uploadMediaFiles(
files: File[],
options?: {
onProgress?: (fileKey: string, percent: number) => void
extraFields?: Record<string, string>
}
): Promise<SerializedMedia[]> {
const uploadPromises = files.map(async (file) => {
const formData = new FormData()
formData.append('file', file)
// Add any extra fields (e.g., description)
if (options?.extraFields) {
Object.entries(options.extraFields).forEach(([key, value]) => {
formData.append(key, value)
})
}
const response = await fetch('/api/media/upload', {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `Upload failed for ${file.name}`)
}
const result = await response.json()
return result as SerializedMedia
})
return Promise.all(uploadPromises)
}