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 SmartImage from '../SmartImage.svelte'
|
||||||
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||||
import MediaDetailsModal from './MediaDetailsModal.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
|
// Gallery items can be either Media objects or objects with a mediaId reference
|
||||||
type GalleryItem = Media | (Partial<Media> & { mediaId?: number })
|
type GalleryItem = Media | (Partial<Media> & { mediaId?: number })
|
||||||
|
|
@ -55,43 +57,9 @@
|
||||||
const canAddMore = $derived(!maxItems || !value || value.length < maxItems)
|
const canAddMore = $derived(!maxItems || !value || value.length < maxItems)
|
||||||
const remainingSlots = $derived(maxItems ? maxItems - (value?.length || 0) : Infinity)
|
const remainingSlots = $derived(maxItems ? maxItems - (value?.length || 0) : Infinity)
|
||||||
|
|
||||||
// File validation
|
// File validation using shared helper
|
||||||
function validateFile(file: File): string | null {
|
function validateFile(file: File): string | null {
|
||||||
// Check file type
|
return validateImageFile(file, maxFileSize)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle file selection/drop
|
// Handle file selection/drop
|
||||||
|
|
@ -140,7 +108,8 @@
|
||||||
}, 100)
|
}, 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
const uploadedMedia = await uploadFiles(filesToUpload)
|
// Upload files using shared helper
|
||||||
|
const uploadedMedia = await uploadMediaFiles(filesToUpload) as Media[]
|
||||||
|
|
||||||
// Clear progress intervals
|
// Clear progress intervals
|
||||||
progressIntervals.forEach((interval) => clearInterval(interval))
|
progressIntervals.forEach((interval) => clearInterval(interval))
|
||||||
|
|
@ -459,54 +428,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Upload Prompt -->
|
<!-- Upload Prompt -->
|
||||||
<div class="upload-prompt">
|
<div class="upload-prompt">
|
||||||
<svg
|
<FileIcon size={48} class="upload-icon" />
|
||||||
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>
|
|
||||||
<p class="upload-main-text">{placeholder}</p>
|
<p class="upload-main-text">{placeholder}</p>
|
||||||
<p class="upload-sub-text">
|
<p class="upload-sub-text">
|
||||||
Supports JPG, PNG, GIF up to {maxFileSize}MB
|
Supports JPG, PNG, GIF up to {maxFileSize}MB
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
import SmartImage from '../SmartImage.svelte'
|
import SmartImage from '../SmartImage.svelte'
|
||||||
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||||
import RefreshIcon from '$icons/refresh.svg?component'
|
import RefreshIcon from '$icons/refresh.svg?component'
|
||||||
|
import FileIcon from '$icons/FileIcon.svelte'
|
||||||
|
import { validateImageFile, uploadMediaFiles } from '$lib/utils/mediaHelpers'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -56,45 +58,22 @@
|
||||||
return `aspect-ratio: ${w}/${h}; padding-bottom: ${ratio}%;`
|
return `aspect-ratio: ${w}/${h}; padding-bottom: ${ratio}%;`
|
||||||
})
|
})
|
||||||
|
|
||||||
// File validation
|
// File validation using shared helper
|
||||||
function validateFile(file: File): string | null {
|
function validateFile(file: File): string | null {
|
||||||
// Check file type
|
return validateImageFile(file, maxFileSize)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload file to server
|
// Upload file to server using shared helper
|
||||||
async function uploadFile(file: File): Promise<Media> {
|
async function uploadFile(file: File): Promise<Media> {
|
||||||
const formData = new FormData()
|
const extraFields: Record<string, string> = {}
|
||||||
formData.append('file', file)
|
|
||||||
|
// Add description if provided
|
||||||
// Removed altText upload - description is handled separately
|
|
||||||
|
|
||||||
if (descriptionValue.trim()) {
|
if (descriptionValue.trim()) {
|
||||||
formData.append('description', descriptionValue.trim())
|
extraFields.description = descriptionValue.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/media/upload', {
|
const uploadedMedia = await uploadMediaFiles([file], { extraFields })
|
||||||
method: 'POST',
|
return uploadedMedia[0] as Media
|
||||||
body: formData,
|
|
||||||
credentials: 'same-origin'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json()
|
|
||||||
throw new Error(errorData.error || 'Upload failed')
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle file selection/drop
|
// Handle file selection/drop
|
||||||
|
|
@ -420,54 +399,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Upload Prompt -->
|
<!-- Upload Prompt -->
|
||||||
<div class="upload-prompt">
|
<div class="upload-prompt">
|
||||||
<svg
|
<FileIcon size={48} class="upload-icon" />
|
||||||
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>
|
|
||||||
<p class="upload-main-text">{placeholder}</p>
|
<p class="upload-main-text">{placeholder}</p>
|
||||||
<p class="upload-sub-text">
|
<p class="upload-sub-text">
|
||||||
Supports JPG, PNG, GIF up to {maxFileSize}MB
|
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
|
* 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`
|
if (bitrate < 1000000) return `${(bitrate / 1000).toFixed(0)} kbps`
|
||||||
return `${(bitrate / 1000000).toFixed(1)} Mbps`
|
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