695 lines
14 KiB
Svelte
695 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation'
|
|
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
|
import Button from '$lib/components/admin/Button.svelte'
|
|
import { onMount } from 'svelte'
|
|
|
|
let files = $state<File[]>([])
|
|
let dragActive = $state(false)
|
|
let isUploading = $state(false)
|
|
let uploadProgress = $state<Record<string, number>>({})
|
|
let uploadErrors = $state<string[]>([])
|
|
let successCount = $state(0)
|
|
let fileInput: HTMLInputElement
|
|
|
|
onMount(() => {
|
|
// Check authentication
|
|
const auth = localStorage.getItem('admin_auth')
|
|
if (!auth) {
|
|
goto('/admin/login')
|
|
}
|
|
})
|
|
|
|
function handleDragOver(event: DragEvent) {
|
|
event.preventDefault()
|
|
dragActive = true
|
|
}
|
|
|
|
function handleDragLeave(event: DragEvent) {
|
|
event.preventDefault()
|
|
dragActive = false
|
|
}
|
|
|
|
function handleDrop(event: DragEvent) {
|
|
event.preventDefault()
|
|
dragActive = false
|
|
|
|
const droppedFiles = Array.from(event.dataTransfer?.files || [])
|
|
addFiles(droppedFiles)
|
|
}
|
|
|
|
function handleFileSelect(event: Event) {
|
|
const target = event.target as HTMLInputElement
|
|
const selectedFiles = Array.from(target.files || [])
|
|
addFiles(selectedFiles)
|
|
}
|
|
|
|
function addFiles(newFiles: File[]) {
|
|
// Filter for image files
|
|
const imageFiles = newFiles.filter((file) => file.type.startsWith('image/'))
|
|
|
|
if (imageFiles.length !== newFiles.length) {
|
|
uploadErrors = [
|
|
...uploadErrors,
|
|
`${newFiles.length - imageFiles.length} non-image files were skipped`
|
|
]
|
|
}
|
|
|
|
files = [...files, ...imageFiles]
|
|
}
|
|
|
|
function removeFile(index: number) {
|
|
files = files.filter((_, i) => i !== index)
|
|
// Clear any related upload progress
|
|
const fileName = files[index]?.name
|
|
if (fileName && uploadProgress[fileName]) {
|
|
const { [fileName]: removed, ...rest } = uploadProgress
|
|
uploadProgress = rest
|
|
}
|
|
}
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes === 0) return '0 Bytes'
|
|
const k = 1024
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
}
|
|
|
|
async function uploadFiles() {
|
|
if (files.length === 0) return
|
|
|
|
isUploading = true
|
|
uploadErrors = []
|
|
successCount = 0
|
|
uploadProgress = {}
|
|
|
|
const auth = localStorage.getItem('admin_auth')
|
|
if (!auth) {
|
|
uploadErrors = ['Authentication required']
|
|
isUploading = false
|
|
return
|
|
}
|
|
|
|
// Upload files individually to show progress
|
|
for (const file of files) {
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const response = await fetch('/api/media/upload', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Basic ${auth}`
|
|
},
|
|
body: formData
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
uploadErrors = [...uploadErrors, `${file.name}: ${error.message || 'Upload failed'}`]
|
|
} else {
|
|
successCount++
|
|
uploadProgress = { ...uploadProgress, [file.name]: 100 }
|
|
}
|
|
} catch (error) {
|
|
uploadErrors = [...uploadErrors, `${file.name}: Network error`]
|
|
}
|
|
}
|
|
|
|
isUploading = false
|
|
|
|
// If all uploads succeeded, redirect back to media library
|
|
if (successCount === files.length && uploadErrors.length === 0) {
|
|
setTimeout(() => {
|
|
goto('/admin/media')
|
|
}, 1500)
|
|
}
|
|
}
|
|
|
|
function clearAll() {
|
|
files = []
|
|
uploadProgress = {}
|
|
uploadErrors = []
|
|
successCount = 0
|
|
}
|
|
</script>
|
|
|
|
<AdminPage>
|
|
<header slot="header">
|
|
<h1>Upload Media</h1>
|
|
<div class="header-actions">
|
|
<Button variant="secondary" onclick={() => goto('/admin/media')}>
|
|
← Back to Media Library
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="upload-container">
|
|
<!-- File List -->
|
|
{#if files.length > 0}
|
|
<div class="file-list">
|
|
<div class="file-list-header">
|
|
<h3>Files to Upload</h3>
|
|
<div class="file-actions">
|
|
<Button
|
|
variant="primary"
|
|
buttonSize="small"
|
|
onclick={uploadFiles}
|
|
disabled={isUploading || files.length === 0}
|
|
loading={isUploading}
|
|
>
|
|
{isUploading
|
|
? 'Uploading...'
|
|
: `Upload ${files.length} File${files.length !== 1 ? 's' : ''}`}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
buttonSize="icon"
|
|
onclick={clearAll}
|
|
disabled={isUploading}
|
|
title="Clear all files"
|
|
>
|
|
<svg
|
|
slot="icon"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<line x1="8" y1="8" x2="16" y2="16"></line>
|
|
<line x1="16" y1="8" x2="8" y2="16"></line>
|
|
</svg>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="files">
|
|
{#each files as file, index}
|
|
<div class="file-item">
|
|
<div class="file-preview">
|
|
{#if file.type.startsWith('image/')}
|
|
<img src={URL.createObjectURL(file)} alt={file.name} />
|
|
{:else}
|
|
<div class="file-icon">📄</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="file-info">
|
|
<div class="file-name">{file.name}</div>
|
|
<div class="file-size">{formatFileSize(file.size)}</div>
|
|
|
|
{#if isUploading}
|
|
<div class="progress-bar">
|
|
<div
|
|
class="progress-fill"
|
|
style="width: {uploadProgress[file.name] || 0}%"
|
|
></div>
|
|
</div>
|
|
<div class="upload-status">
|
|
{#if uploadProgress[file.name] === 100}
|
|
<span class="status-complete">✓ Complete</span>
|
|
{:else if uploadProgress[file.name] > 0}
|
|
<span class="status-uploading"
|
|
>{Math.round(uploadProgress[file.name] || 0)}%</span
|
|
>
|
|
{:else}
|
|
<span class="status-waiting">Waiting...</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !isUploading}
|
|
<button
|
|
type="button"
|
|
class="remove-button"
|
|
onclick={() => removeFile(index)}
|
|
title="Remove file"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Drop Zone -->
|
|
<div
|
|
class="drop-zone"
|
|
class:active={dragActive}
|
|
class:has-files={files.length > 0}
|
|
class:compact={files.length > 0}
|
|
class:uploading={isUploading}
|
|
ondragover={handleDragOver}
|
|
ondragleave={handleDragLeave}
|
|
ondrop={handleDrop}
|
|
>
|
|
<div class="drop-zone-content">
|
|
{#if files.length === 0}
|
|
<div class="upload-icon">
|
|
<svg
|
|
width="48"
|
|
height="48"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<polyline
|
|
points="14,2 14,8 20,8"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<line
|
|
x1="16"
|
|
y1="13"
|
|
x2="8"
|
|
y2="13"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<line
|
|
x1="16"
|
|
y1="17"
|
|
x2="8"
|
|
y2="17"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<polyline
|
|
points="10,9 9,9 8,9"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h3>Drop images here</h3>
|
|
<p>or click to browse and select files</p>
|
|
<p class="upload-hint">Supports JPG, PNG, GIF, WebP, and SVG files</p>
|
|
{:else}
|
|
<div class="compact-content">
|
|
<svg
|
|
class="add-icon"
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<line
|
|
x1="12"
|
|
y1="5"
|
|
x2="12"
|
|
y2="19"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
/>
|
|
<line
|
|
x1="5"
|
|
y1="12"
|
|
x2="19"
|
|
y2="12"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
/>
|
|
</svg>
|
|
<span>Add more files or drop them here</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
multiple
|
|
accept="image/*"
|
|
onchange={handleFileSelect}
|
|
class="hidden-input"
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
class="drop-zone-button"
|
|
onclick={() => fileInput.click()}
|
|
disabled={isUploading}
|
|
>
|
|
{dragActive ? 'Drop files' : 'Click to browse'}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Upload Results -->
|
|
{#if successCount > 0 || uploadErrors.length > 0}
|
|
<div class="upload-results">
|
|
{#if successCount > 0}
|
|
<div class="success-message">
|
|
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
|
|
{#if successCount === files.length && uploadErrors.length === 0}
|
|
<br /><small>Redirecting to media library...</small>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if uploadErrors.length > 0}
|
|
<div class="error-messages">
|
|
<h4>Upload Errors:</h4>
|
|
{#each uploadErrors as error}
|
|
<div class="error-item">❌ {error}</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</AdminPage>
|
|
|
|
<style lang="scss">
|
|
.upload-container {
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
padding: $unit-4x;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: $unit-2x;
|
|
}
|
|
|
|
.drop-zone {
|
|
border: 2px dashed $grey-80;
|
|
border-radius: $unit-2x;
|
|
padding: $unit-6x $unit-4x;
|
|
text-align: center;
|
|
position: relative;
|
|
background: $grey-95;
|
|
transition: all 0.2s ease;
|
|
margin-bottom: $unit-4x;
|
|
|
|
&.active {
|
|
border-color: #3b82f6;
|
|
background: rgba(59, 130, 246, 0.05);
|
|
}
|
|
|
|
&.has-files {
|
|
padding: $unit-4x;
|
|
}
|
|
|
|
&.compact {
|
|
padding: $unit-3x;
|
|
min-height: auto;
|
|
|
|
.drop-zone-content {
|
|
.compact-content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: $unit-2x;
|
|
color: $grey-40;
|
|
font-size: 0.875rem;
|
|
|
|
.add-icon {
|
|
color: $grey-50;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
&:hover {
|
|
border-color: $grey-60;
|
|
background: $grey-90;
|
|
}
|
|
|
|
&.uploading {
|
|
border-color: #3b82f6;
|
|
border-style: solid;
|
|
background: rgba(59, 130, 246, 0.02);
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
|
|
.drop-zone-content {
|
|
pointer-events: none;
|
|
|
|
.upload-icon {
|
|
color: $grey-50;
|
|
margin-bottom: $unit-2x;
|
|
}
|
|
|
|
h3 {
|
|
font-size: 1.25rem;
|
|
color: $grey-20;
|
|
margin-bottom: $unit;
|
|
}
|
|
|
|
p {
|
|
color: $grey-40;
|
|
margin-bottom: $unit-half;
|
|
}
|
|
|
|
.upload-hint {
|
|
font-size: 0.875rem;
|
|
color: $grey-50;
|
|
}
|
|
|
|
.file-count {
|
|
strong {
|
|
color: $grey-20;
|
|
font-size: 1.1rem;
|
|
}
|
|
}
|
|
}
|
|
|
|
.hidden-input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.drop-zone-button {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: transparent;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: transparent;
|
|
|
|
&:disabled {
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
|
|
.file-list {
|
|
background: white;
|
|
border: 1px solid $grey-85;
|
|
border-radius: $unit-2x;
|
|
padding: $unit-3x;
|
|
margin-bottom: $unit-3x;
|
|
}
|
|
|
|
.file-list-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: $unit-3x;
|
|
padding-bottom: $unit-2x;
|
|
border-bottom: 1px solid $grey-85;
|
|
|
|
h3 {
|
|
margin: 0;
|
|
color: $grey-20;
|
|
}
|
|
|
|
.file-actions {
|
|
display: flex;
|
|
gap: $unit-2x;
|
|
align-items: center;
|
|
}
|
|
}
|
|
|
|
.files {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $unit-2x;
|
|
}
|
|
|
|
.file-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: $unit-3x;
|
|
padding: $unit-2x;
|
|
background: $grey-95;
|
|
border-radius: $unit;
|
|
border: 1px solid $grey-85;
|
|
}
|
|
|
|
.file-preview {
|
|
width: 60px;
|
|
height: 60px;
|
|
border-radius: $unit;
|
|
overflow: hidden;
|
|
background: $grey-90;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
|
|
img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.file-icon {
|
|
font-size: 1.5rem;
|
|
}
|
|
}
|
|
|
|
.file-info {
|
|
flex: 1;
|
|
|
|
.file-name {
|
|
font-weight: 500;
|
|
color: $grey-20;
|
|
margin-bottom: $unit-half;
|
|
}
|
|
|
|
.file-size {
|
|
font-size: 0.875rem;
|
|
color: $grey-50;
|
|
margin-bottom: $unit-half;
|
|
}
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 6px;
|
|
background: $grey-90;
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
margin-bottom: $unit-half;
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: #3b82f6;
|
|
transition: width 0.3s ease;
|
|
position: relative;
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
bottom: 0;
|
|
right: 0;
|
|
background: linear-gradient(
|
|
90deg,
|
|
transparent 30%,
|
|
rgba(255, 255, 255, 0.2) 50%,
|
|
transparent 70%
|
|
);
|
|
animation: shimmer 1.5s infinite;
|
|
}
|
|
}
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% {
|
|
transform: translateX(-100%);
|
|
}
|
|
100% {
|
|
transform: translateX(100%);
|
|
}
|
|
}
|
|
|
|
.upload-status {
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
|
|
.status-complete {
|
|
color: #16a34a;
|
|
}
|
|
|
|
.status-uploading {
|
|
color: #3b82f6;
|
|
}
|
|
|
|
.status-waiting {
|
|
color: $grey-50;
|
|
}
|
|
}
|
|
|
|
.remove-button {
|
|
background: none;
|
|
border: none;
|
|
color: $grey-50;
|
|
cursor: pointer;
|
|
padding: $unit;
|
|
border-radius: 50%;
|
|
transition: all 0.2s ease;
|
|
|
|
&:hover {
|
|
background: $red-60;
|
|
color: white;
|
|
}
|
|
}
|
|
|
|
.upload-results {
|
|
background: white;
|
|
border: 1px solid $grey-85;
|
|
border-radius: $unit-2x;
|
|
padding: $unit-3x;
|
|
|
|
.success-message {
|
|
color: #16a34a;
|
|
margin-bottom: $unit-2x;
|
|
|
|
small {
|
|
color: $grey-50;
|
|
}
|
|
}
|
|
|
|
.error-messages {
|
|
h4 {
|
|
color: $red-60;
|
|
margin-bottom: $unit-2x;
|
|
}
|
|
|
|
.error-item {
|
|
color: $red-60;
|
|
margin-bottom: $unit;
|
|
font-size: 0.925rem;
|
|
}
|
|
}
|
|
}
|
|
</style>
|