jedmund-svelte/src/lib/components/admin/ImageUploader.svelte

859 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import type { Media } from '@prisma/client'
import Button from './Button.svelte'
import Input from './Input.svelte'
import SmartImage from '../SmartImage.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import { authenticatedFetch } from '$lib/admin-auth'
import RefreshIcon from '$icons/refresh.svg?component'
interface Props {
label: string
value?: Media | null
onUpload: (media: Media) => void
onRemove?: () => void
aspectRatio?: string // e.g., "16:9", "1:1"
required?: boolean
error?: string
allowAltText?: boolean
maxFileSize?: number // MB limit
placeholder?: string
helpText?: string
showBrowseLibrary?: boolean // Show secondary "Browse Library" button
compact?: boolean // Use compact layout with smaller preview and side-by-side alt text
}
let {
label,
value = $bindable(),
onUpload,
onRemove,
aspectRatio,
required = false,
error,
allowAltText = true,
maxFileSize = 10,
placeholder = 'Drag and drop an image here, or click to browse',
helpText,
showBrowseLibrary = false,
compact = false
}: Props = $props()
// State
let isUploading = $state(false)
let uploadProgress = $state(0)
let uploadError = $state<string | null>(null)
let isDragOver = $state(false)
let fileInputElement: HTMLInputElement
let altTextValue = $state(value?.altText || '')
let descriptionValue = $state(value?.description || '')
let isMediaLibraryOpen = $state(false)
// Computed properties
const hasValue = $derived(!!value)
const aspectRatioStyle = $derived(() => {
if (!aspectRatio) return ''
const [w, h] = aspectRatio.split(':').map(Number)
const ratio = (h / w) * 100
return `aspect-ratio: ${w}/${h}; padding-bottom: ${ratio}%;`
})
// File validation
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
}
// Upload file to server
async function uploadFile(file: File): Promise<Media> {
const formData = new FormData()
formData.append('file', file)
if (allowAltText && altTextValue.trim()) {
formData.append('altText', altTextValue.trim())
}
if (allowAltText && descriptionValue.trim()) {
formData.append('description', descriptionValue.trim())
}
const response = await authenticatedFetch('/api/media/upload', {
method: 'POST',
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Upload failed')
}
return await response.json()
}
// Handle file selection/drop
async function handleFiles(files: FileList) {
if (files.length === 0) return
const file = files[0]
const validationError = validateFile(file)
if (validationError) {
uploadError = validationError
return
}
uploadError = null
isUploading = true
uploadProgress = 0
try {
// Simulate progress for user feedback
const progressInterval = setInterval(() => {
if (uploadProgress < 90) {
uploadProgress += Math.random() * 10
}
}, 100)
const uploadedMedia = await uploadFile(file)
clearInterval(progressInterval)
uploadProgress = 100
// Brief delay to show completion
setTimeout(() => {
value = uploadedMedia
altTextValue = uploadedMedia.altText || ''
descriptionValue = uploadedMedia.description || ''
onUpload(uploadedMedia)
isUploading = false
uploadProgress = 0
}, 500)
} catch (err) {
isUploading = false
uploadProgress = 0
uploadError = err instanceof Error ? err.message : 'Upload failed'
}
}
// Drag and drop handlers
function handleDragOver(event: DragEvent) {
event.preventDefault()
isDragOver = true
}
function handleDragLeave(event: DragEvent) {
event.preventDefault()
isDragOver = false
}
function handleDrop(event: DragEvent) {
event.preventDefault()
isDragOver = false
const files = event.dataTransfer?.files
if (files) {
handleFiles(files)
}
}
// Click to browse handler
function handleBrowseClick() {
fileInputElement?.click()
}
function handleFileInputChange(event: Event) {
const target = event.target as HTMLInputElement
if (target.files) {
handleFiles(target.files)
}
}
// Remove uploaded image
function handleRemove() {
value = null
altTextValue = ''
descriptionValue = ''
uploadError = null
onRemove?.()
}
// Update alt text on server
async function handleAltTextChange() {
if (!value) return
try {
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
altText: altTextValue.trim() || null
})
})
if (response.ok) {
const updatedData = await response.json()
value = { ...value, altText: updatedData.altText, updatedAt: updatedData.updatedAt }
}
} catch (error) {
console.error('Failed to update alt text:', error)
}
}
async function handleDescriptionChange() {
if (!value) return
try {
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
description: descriptionValue.trim() || null
})
})
if (response.ok) {
const updatedData = await response.json()
value = { ...value, description: updatedData.description, updatedAt: updatedData.updatedAt }
}
} catch (error) {
console.error('Failed to update description:', error)
}
}
// Browse library handler
function handleBrowseLibrary() {
isMediaLibraryOpen = true
}
function handleMediaSelect(selectedMedia: Media | Media[]) {
// Since this is single mode, selectedMedia will be a single Media object
const media = selectedMedia as Media
value = media
altTextValue = media.altText || ''
descriptionValue = media.description || ''
onUpload(media)
}
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
</script>
<div class="image-uploader" class:compact>
<!-- Label -->
<label class="uploader-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
{#if helpText}
<p class="help-text">{helpText}</p>
{/if}
<!-- Upload Area or Preview -->
<div class="upload-container">
{#if hasValue && !isUploading}
{#if compact}
<!-- Compact Layout: Image and metadata side-by-side -->
<div class="compact-preview">
<div class="compact-image">
<SmartImage
media={value}
alt={value?.altText || value?.filename || 'Uploaded image'}
containerWidth={100}
loading="eager"
{aspectRatio}
class="preview-image"
/>
<!-- Overlay with actions -->
<div class="preview-overlay">
<div class="preview-actions">
<Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}>
<RefreshIcon slot="icon" width="12" height="12" />
</Button>
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
<svg
slot="icon"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="3,6 5,6 21,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
</div>
</div>
</div>
<div class="compact-info">
<!-- Alt Text Input in compact mode -->
{#if allowAltText}
<div class="compact-metadata">
<Input
type="text"
label="Alt Text"
bind:value={altTextValue}
placeholder="Describe this image for screen readers"
buttonSize="small"
onblur={handleAltTextChange}
/>
<Input
type="textarea"
label="Description (Optional)"
bind:value={descriptionValue}
placeholder="Additional description or caption"
rows={2}
buttonSize="small"
onblur={handleDescriptionChange}
/>
</div>
{/if}
</div>
</div>
{:else}
<!-- Standard Layout: Image preview -->
<div class="image-preview" style={aspectRatioStyle}>
<SmartImage
media={value}
alt={value?.altText || value?.filename || 'Uploaded image'}
containerWidth={800}
loading="eager"
{aspectRatio}
class="preview-image"
/>
<!-- Overlay with actions -->
<div class="preview-overlay">
<div class="preview-actions">
<Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}>
<RefreshIcon slot="icon" width="16" height="16" />
Replace
</Button>
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="3,6 5,6 21,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Remove
</Button>
</div>
</div>
</div>
<!-- File Info -->
<div class="file-info">
<p class="filename">{value?.originalName || value?.filename}</p>
<p class="file-meta">
{Math.round((value?.size || 0) / 1024)} KB
{#if value?.width && value?.height}
{value.width}×{value.height}
{/if}
</p>
</div>
{/if}
{:else}
<!-- Upload Drop Zone -->
<div
class="drop-zone"
class:drag-over={isDragOver}
class:uploading={isUploading}
class:has-error={!!uploadError}
style={aspectRatioStyle}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={handleBrowseClick}
>
{#if isUploading}
<!-- Upload Progress -->
<div class="upload-progress">
<svg class="upload-spinner" width="24" height="24" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-dasharray="60"
stroke-dashoffset="60"
stroke-linecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="1s"
repeatCount="indefinite"
/>
</circle>
</svg>
<p class="upload-text">Uploading... {Math.round(uploadProgress)}%</p>
<div class="progress-bar">
<div class="progress-fill" style="width: {uploadProgress}%"></div>
</div>
</div>
{: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>
<p class="upload-main-text">{placeholder}</p>
<p class="upload-sub-text">
Supports JPG, PNG, GIF up to {maxFileSize}MB
</p>
</div>
{/if}
</div>
{/if}
</div>
<!-- Action Buttons -->
{#if !hasValue && !isUploading}
<div class="action-buttons">
<Button variant="primary" onclick={handleBrowseClick}>Choose File</Button>
{#if showBrowseLibrary}
<Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
{/if}
</div>
{/if}
<!-- Alt Text Input (only in standard mode, compact mode has it inline) -->
{#if allowAltText && hasValue && !compact}
<div class="metadata-section">
<Input
type="text"
label="Alt Text"
bind:value={altTextValue}
placeholder="Describe this image for screen readers"
helpText="Help make your content accessible. Describe what's in the image."
onblur={handleAltTextChange}
/>
<Input
type="textarea"
label="Description (Optional)"
bind:value={descriptionValue}
placeholder="Additional description or caption"
rows={2}
onblur={handleDescriptionChange}
/>
</div>
{/if}
<!-- Error Message -->
{#if error || uploadError}
<p class="error-message">{error || uploadError}</p>
{/if}
<!-- Hidden File Input -->
<input
bind:this={fileInputElement}
type="file"
accept="image/*"
style="display: none;"
onchange={handleFileInputChange}
/>
</div>
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={isMediaLibraryOpen}
mode="single"
fileType="image"
title="Select Image"
confirmText="Select Image"
onSelect={handleMediaSelect}
onClose={handleMediaLibraryClose}
/>
<style lang="scss">
.image-uploader {
display: flex;
flex-direction: column;
gap: $unit-2x;
&.compact {
gap: $unit;
}
}
.uploader-label {
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
.required {
color: $red-60;
margin-left: $unit-half;
}
}
.help-text {
margin: 0;
font-size: 0.8rem;
color: $grey-40;
line-height: 1.4;
}
.upload-container {
position: relative;
}
// Drop Zone Styles
.drop-zone {
border: 2px dashed $grey-80;
border-radius: $card-corner-radius;
background-color: $grey-97;
cursor: pointer;
transition: all 0.2s ease;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&:hover {
border-color: $blue-60;
background-color: rgba($blue-60, 0.02);
}
&.drag-over {
border-color: $blue-60;
background-color: rgba($blue-60, 0.05);
border-style: solid;
}
&.uploading {
cursor: default;
border-color: $blue-60;
}
&.has-error {
border-color: $red-60;
background-color: rgba($red-60, 0.02);
}
}
.upload-prompt {
text-align: center;
padding: $unit-4x;
.upload-icon {
color: $grey-50;
margin-bottom: $unit-2x;
}
.upload-main-text {
margin: 0 0 $unit 0;
font-size: 0.875rem;
color: $grey-30;
font-weight: 500;
}
.upload-sub-text {
margin: 0;
font-size: 0.75rem;
color: $grey-50;
}
}
.upload-progress {
text-align: center;
padding: $unit-4x;
.upload-spinner {
color: $blue-60;
margin-bottom: $unit-2x;
}
.upload-text {
margin: 0 0 $unit-2x 0;
font-size: 0.875rem;
color: $grey-30;
font-weight: 500;
}
.progress-bar {
width: 200px;
height: 4px;
background-color: $grey-90;
border-radius: 2px;
overflow: hidden;
margin: 0 auto;
.progress-fill {
height: 100%;
background-color: $blue-60;
transition: width 0.3s ease;
}
}
}
// Image Preview Styles
.image-preview {
position: relative;
border-radius: $card-corner-radius;
overflow: hidden;
background-color: $grey-95;
min-height: 200px;
:global(.preview-image) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover .preview-overlay {
opacity: 1;
}
.preview-actions {
display: flex;
gap: $unit;
}
}
.file-info {
margin-top: $unit-2x;
.filename {
margin: 0 0 $unit-half 0;
font-size: 0.875rem;
font-weight: 500;
color: $grey-10;
}
.file-meta {
margin: 0;
font-size: 0.75rem;
color: $grey-40;
}
}
.action-buttons {
display: flex;
gap: $unit-2x;
align-items: center;
}
.metadata-section {
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: $unit-3x;
background-color: $grey-97;
border-radius: $card-corner-radius;
border: 1px solid $grey-90;
}
.error-message {
margin: 0;
font-size: 0.75rem;
color: $red-60;
padding: $unit;
background-color: rgba($red-60, 0.05);
border-radius: $card-corner-radius;
border: 1px solid rgba($red-60, 0.2);
}
// Compact layout styles
.compact-preview {
display: flex;
gap: $unit-3x;
align-items: flex-start;
}
.compact-image {
position: relative;
width: 100px;
height: 100px;
flex-shrink: 0;
border-radius: $card-corner-radius;
overflow: hidden;
background-color: $grey-95;
border: 1px solid $grey-90;
:global(.preview-image) {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
padding: $unit-3x;
box-sizing: border-box;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover .preview-overlay {
opacity: 1;
}
.preview-actions {
display: flex;
gap: $unit-half;
}
}
.compact-info {
flex: 1;
display: flex;
flex-direction: column;
.compact-metadata {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
}
// Responsive adjustments
@media (max-width: 640px) {
.upload-prompt {
padding: $unit-3x;
.upload-main-text {
font-size: 0.8rem;
}
}
.action-buttons {
flex-direction: column;
align-items: stretch;
}
.preview-actions {
flex-direction: column;
}
}
</style>