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:
parent
3ec59dc996
commit
d128c95662
3 changed files with 99 additions and 165 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const sizeMB = file.size / 1024 / 1024
|
||||
if (sizeMB > maxFileSize) {
|
||||
return `File size must be less than ${maxFileSize}MB`
|
||||
}
|
||||
|
||||
return null
|
||||
return validateImageFile(file, maxFileSize)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue