refactor: update media modals to use extracted components
- Replace inline grid with MediaGrid component - Replace upload zone with FileUploadZone component - Replace file list with FilePreviewList component - Replace metadata sections with dedicated components - Simplify media selection to idiomatic Svelte patterns - Remove non-idiomatic composable pattern This completes the media modal consolidation, reducing duplication and improving maintainability. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2ec0be092e
commit
64b5a8e73c
3 changed files with 109 additions and 1131 deletions
|
|
@ -9,8 +9,11 @@
|
||||||
import CloseButton from '$components/icons/CloseButton.svelte'
|
import CloseButton from '$components/icons/CloseButton.svelte'
|
||||||
import FileIcon from '$components/icons/FileIcon.svelte'
|
import FileIcon from '$components/icons/FileIcon.svelte'
|
||||||
import CopyIcon from '$components/icons/CopyIcon.svelte'
|
import CopyIcon from '$components/icons/CopyIcon.svelte'
|
||||||
|
import MediaMetadataPanel from './MediaMetadataPanel.svelte'
|
||||||
|
import MediaUsageList from './MediaUsageList.svelte'
|
||||||
import { authenticatedFetch } from '$lib/admin-auth'
|
import { authenticatedFetch } from '$lib/admin-auth'
|
||||||
import { toast } from '$lib/stores/toast'
|
import { toast } from '$lib/stores/toast'
|
||||||
|
import { formatFileSize, getFileType } from '$lib/utils/mediaHelpers'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -45,15 +48,11 @@
|
||||||
let loadingAlbums = $state(false)
|
let loadingAlbums = $state(false)
|
||||||
let showAlbumSelector = $state(false)
|
let showAlbumSelector = $state(false)
|
||||||
|
|
||||||
// EXIF toggle state
|
|
||||||
let showExif = $state(false)
|
|
||||||
|
|
||||||
// Initialize form when media changes
|
// Initialize form when media changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (media) {
|
if (media) {
|
||||||
description = media.description || ''
|
description = media.description || ''
|
||||||
isPhotography = media.isPhotography || false
|
isPhotography = media.isPhotography || false
|
||||||
showExif = false
|
|
||||||
loadUsage()
|
loadUsage()
|
||||||
// Only load albums for images
|
// Only load albums for images
|
||||||
if (media.mimeType?.startsWith('image/')) {
|
if (media.mimeType?.startsWith('image/')) {
|
||||||
|
|
@ -204,21 +203,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileType(mimeType: string): string {
|
|
||||||
if (mimeType.startsWith('image/')) return 'Image'
|
|
||||||
if (mimeType.startsWith('video/')) return 'Video'
|
|
||||||
if (mimeType.startsWith('audio/')) return 'Audio'
|
|
||||||
if (mimeType.includes('pdf')) return 'PDF'
|
|
||||||
return 'File'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if media}
|
{#if media}
|
||||||
|
|
@ -262,120 +246,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pane-body">
|
<div class="pane-body">
|
||||||
<div class="file-info">
|
<!-- Media Metadata Panel -->
|
||||||
<div class="info-grid">
|
<MediaMetadataPanel {media} showExifToggle={true} />
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Type</span>
|
|
||||||
<span class="value">{getFileType(media.mimeType)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Size</span>
|
|
||||||
<span class="value">{formatFileSize(media.size)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showExif}
|
|
||||||
<div class="details-data">
|
|
||||||
<!-- Media metadata -->
|
|
||||||
<div class="media-metadata">
|
|
||||||
{#if media.width && media.height}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Dimensions</span>
|
|
||||||
<span class="value">{media.width} × {media.height}px</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if media.dominantColor}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Dominant Color</span>
|
|
||||||
<span class="value color-value">
|
|
||||||
<span
|
|
||||||
class="color-swatch"
|
|
||||||
style="background-color: {media.dominantColor}"
|
|
||||||
title={media.dominantColor}
|
|
||||||
></span>
|
|
||||||
{media.dominantColor}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Uploaded</span>
|
|
||||||
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- EXIF metadata -->
|
|
||||||
{#if media.exifData && Object.keys(media.exifData).length > 0}
|
|
||||||
<div class="metadata-divider"></div>
|
|
||||||
<div class="exif-metadata">
|
|
||||||
{#if media.exifData.camera}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Camera</span>
|
|
||||||
<span class="value">{media.exifData.camera}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if media.exifData.lens}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Lens</span>
|
|
||||||
<span class="value">{media.exifData.lens}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if media.exifData.focalLength}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Focal Length</span>
|
|
||||||
<span class="value">{media.exifData.focalLength}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if media.exifData.aperture}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Aperture</span>
|
|
||||||
<span class="value">{media.exifData.aperture}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if media.exifData.shutterSpeed}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Shutter Speed</span>
|
|
||||||
<span class="value">{media.exifData.shutterSpeed}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if media.exifData.iso}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">ISO</span>
|
|
||||||
<span class="value">{media.exifData.iso}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if media.exifData.dateTaken}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Date Taken</span>
|
|
||||||
<span class="value"
|
|
||||||
>{new Date(media.exifData.dateTaken).toLocaleDateString()}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if media.exifData.coordinates}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">GPS</span>
|
|
||||||
<span class="value">
|
|
||||||
{media.exifData.coordinates.latitude.toFixed(6)},
|
|
||||||
{media.exifData.coordinates.longitude.toFixed(6)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onclick={() => (showExif = !showExif)}
|
|
||||||
buttonSize="small"
|
|
||||||
fullWidth
|
|
||||||
pill={false}
|
|
||||||
class="exif-toggle"
|
|
||||||
>
|
|
||||||
{showExif ? 'Hide Details' : 'Show Details'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pane-body-content">
|
<div class="pane-body-content">
|
||||||
<!-- Photography Toggle -->
|
<!-- Photography Toggle -->
|
||||||
|
|
@ -421,45 +293,7 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if loadingUsage}
|
<MediaUsageList {usage} loading={loadingUsage} />
|
||||||
<div class="usage-loading">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<span>Loading usage information...</span>
|
|
||||||
</div>
|
|
||||||
{:else if usage.length > 0}
|
|
||||||
<ul class="usage-list">
|
|
||||||
{#each usage as usageItem}
|
|
||||||
<li class="usage-item">
|
|
||||||
<div class="usage-content">
|
|
||||||
<div class="usage-header">
|
|
||||||
{#if usageItem.contentUrl}
|
|
||||||
<a
|
|
||||||
href={usageItem.contentUrl}
|
|
||||||
class="usage-title"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
{usageItem.contentTitle}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<span class="usage-title">{usageItem.contentTitle}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="usage-type">{usageItem.contentType}</span>
|
|
||||||
</div>
|
|
||||||
<div class="usage-details">
|
|
||||||
<span class="usage-field">{usageItem.fieldDisplayName}</span>
|
|
||||||
<span class="usage-date"
|
|
||||||
>Added {new Date(usageItem.createdAt).toLocaleDateString()}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<p class="no-usage">This media file is not currently used in any content.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Albums list -->
|
<!-- Albums list -->
|
||||||
{#if albums.length > 0}
|
{#if albums.length > 0}
|
||||||
|
|
@ -611,86 +445,6 @@
|
||||||
gap: $unit-6x;
|
gap: $unit-6x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-3x;
|
|
||||||
padding: $unit-3x;
|
|
||||||
background-color: $gray-90;
|
|
||||||
border-bottom: $unit-1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: $unit-3x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-half;
|
|
||||||
|
|
||||||
&.vertical {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: $gray-50;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: $gray-10;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&.color-value {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-swatch {
|
|
||||||
display: inline-block;
|
|
||||||
width: $unit-20px;
|
|
||||||
height: $unit-20px;
|
|
||||||
border-radius: $corner-radius-xs;
|
|
||||||
border: $unit-1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
box-shadow: inset 0 0 0 $unit-1px rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.btn.btn-ghost.exif-toggle) {
|
|
||||||
margin-top: $unit-2x;
|
|
||||||
justify-content: center;
|
|
||||||
background: transparent;
|
|
||||||
border: $unit-1px solid $gray-70;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.02);
|
|
||||||
border-color: $gray-70;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-metadata,
|
|
||||||
.exif-metadata {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: $unit-3x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-divider {
|
|
||||||
border-radius: $unit-1px;
|
|
||||||
height: $unit-2px;
|
|
||||||
background: $gray-80;
|
|
||||||
margin: $unit-3x 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -816,98 +570,6 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: $unit-2x 0 0 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
padding: $unit-2x;
|
|
||||||
color: $gray-50;
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: $unit-2x;
|
|
||||||
height: $unit-2x;
|
|
||||||
border: $unit-2px solid $gray-90;
|
|
||||||
border-top: $unit-2px solid $gray-50;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-item {
|
|
||||||
padding: $unit-3x;
|
|
||||||
background: $gray-95;
|
|
||||||
border-radius: $corner-radius-xl;
|
|
||||||
border: $unit-1px solid $gray-90;
|
|
||||||
|
|
||||||
.usage-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: $unit-2x;
|
|
||||||
|
|
||||||
.usage-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: $gray-10;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $blue-60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-type {
|
|
||||||
background: $gray-85;
|
|
||||||
color: $gray-30;
|
|
||||||
padding: $unit-half $unit;
|
|
||||||
border-radius: $corner-radius-sm;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-details {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-3x;
|
|
||||||
|
|
||||||
.usage-field {
|
|
||||||
color: $gray-40;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-date {
|
|
||||||
color: $gray-50;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-usage {
|
|
||||||
color: $gray-50;
|
|
||||||
font-style: italic;
|
|
||||||
margin: $unit-2x 0 0 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Albums inline display
|
// Albums inline display
|
||||||
|
|
@ -973,14 +635,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive adjustments
|
// Responsive adjustments
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from './Modal.svelte'
|
import Modal from './Modal.svelte'
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
|
import FileUploadZone from './FileUploadZone.svelte'
|
||||||
|
import FilePreviewList from './FilePreviewList.svelte'
|
||||||
|
import { formatFileSize } from '$lib/utils/mediaHelpers'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
|
|
@ -16,7 +19,6 @@
|
||||||
let uploadProgress = $state<Record<string, number>>({})
|
let uploadProgress = $state<Record<string, number>>({})
|
||||||
let uploadErrors = $state<string[]>([])
|
let uploadErrors = $state<string[]>([])
|
||||||
let successCount = $state(0)
|
let successCount = $state(0)
|
||||||
let fileInput: HTMLInputElement
|
|
||||||
|
|
||||||
// Reset state when modal opens/closes
|
// Reset state when modal opens/closes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -30,28 +32,8 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleDragOver(event: DragEvent) {
|
function handleFilesAdded(newFiles: File[]) {
|
||||||
event.preventDefault()
|
addFiles(newFiles)
|
||||||
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[]) {
|
function addFiles(newFiles: File[]) {
|
||||||
|
|
@ -68,23 +50,19 @@
|
||||||
files = [...files, ...imageFiles]
|
files = [...files, ...imageFiles]
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(index: number) {
|
function removeFile(id: string | number) {
|
||||||
files = files.filter((_, i) => i !== index)
|
// For files, the id is the filename
|
||||||
// Clear any related upload progress
|
const fileToRemove = files.find(f => f.name === id)
|
||||||
const fileName = files[index]?.name
|
if (fileToRemove) {
|
||||||
if (fileName && uploadProgress[fileName]) {
|
files = files.filter(f => f.name !== id)
|
||||||
const { [fileName]: removed, ...rest } = uploadProgress
|
// Clear any related upload progress
|
||||||
uploadProgress = rest
|
if (uploadProgress[fileToRemove.name]) {
|
||||||
|
const { [fileToRemove.name]: 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() {
|
async function uploadFiles() {
|
||||||
if (files.length === 0) return
|
if (files.length === 0) return
|
||||||
|
|
@ -160,209 +138,38 @@
|
||||||
<div class="modal-inner-content">
|
<div class="modal-inner-content">
|
||||||
<!-- File List (shown above drop zone when files are selected) -->
|
<!-- File List (shown above drop zone when files are selected) -->
|
||||||
{#if files.length > 0}
|
{#if files.length > 0}
|
||||||
<div class="files">
|
<FilePreviewList
|
||||||
{#each files as file, index}
|
{files}
|
||||||
<div class="file-item">
|
onRemove={removeFile}
|
||||||
<div class="file-preview">
|
{uploadProgress}
|
||||||
{#if file.type.startsWith('image/')}
|
{isUploading}
|
||||||
<img src={URL.createObjectURL(file)} alt={file.name} />
|
variant="upload"
|
||||||
{: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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !isUploading}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="remove-button"
|
|
||||||
onclick={() => removeFile(index)}
|
|
||||||
title="Remove file"
|
|
||||||
aria-label="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}
|
|
||||||
|
|
||||||
{#if isUploading}
|
|
||||||
<div class="progress-bar-container">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div
|
|
||||||
class="progress-fill"
|
|
||||||
style="width: {uploadProgress[file.name] || 0}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="upload-status">
|
|
||||||
{#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>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Drop Zone (compact when files are selected) -->
|
<!-- Drop Zone (compact when files are selected) -->
|
||||||
<div
|
<FileUploadZone
|
||||||
class="drop-zone"
|
onFilesAdded={handleFilesAdded}
|
||||||
class:active={dragActive}
|
accept={['image/*']}
|
||||||
class:has-files={files.length > 0}
|
multiple={true}
|
||||||
class:compact={files.length > 0}
|
compact={files.length > 0}
|
||||||
ondragover={handleDragOver}
|
disabled={isUploading}
|
||||||
ondragleave={handleDragLeave}
|
{dragActive}
|
||||||
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 -->
|
<!-- Upload Results -->
|
||||||
{#if successCount > 0 || uploadErrors.length > 0}
|
{#if successCount > 0}
|
||||||
<div class="upload-results">
|
<div class="upload-results">
|
||||||
{#if successCount > 0}
|
<div class="success-message">
|
||||||
<div class="success-message">
|
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
|
||||||
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
|
{#if successCount === files.length && uploadErrors.length === 0}
|
||||||
{#if successCount === files.length && uploadErrors.length === 0}
|
<br /><small>Closing modal...</small>
|
||||||
<br /><small>Closing modal...</small>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Error messages are now handled in FilePreviewList -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Footer with actions -->
|
<!-- Modal Footer with actions -->
|
||||||
|
|
@ -428,245 +235,6 @@
|
||||||
background: $gray-95;
|
background: $gray-95;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone {
|
|
||||||
border: 2px dashed $gray-80;
|
|
||||||
border-radius: $unit-2x;
|
|
||||||
padding: $unit-6x $unit-4x;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
background: $gray-95;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&.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: $gray-40;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
|
|
||||||
.add-icon {
|
|
||||||
color: $gray-50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: $gray-60;
|
|
||||||
background: $gray-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: $gray-50;
|
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: $gray-20;
|
|
||||||
margin-bottom: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: $gray-40;
|
|
||||||
margin-bottom: $unit-half;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-hint {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: $gray-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-count {
|
|
||||||
strong {
|
|
||||||
color: $gray-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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.files {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
margin-bottom: $unit-3x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
padding: $unit;
|
|
||||||
background: $gray-95;
|
|
||||||
border-radius: $image-corner-radius;
|
|
||||||
border: 1px solid $gray-85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: $unit;
|
|
||||||
overflow: hidden;
|
|
||||||
background: $gray-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: $gray-20;
|
|
||||||
margin-bottom: $unit-half;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-size {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: $gray-50;
|
|
||||||
margin-bottom: $unit-half;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-container {
|
|
||||||
display: flex;
|
|
||||||
min-width: 120px;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
flex-grow: 1;
|
|
||||||
height: $unit-2x;
|
|
||||||
background: $gray-100;
|
|
||||||
padding: $unit-half;
|
|
||||||
border-radius: $corner-radius-full;
|
|
||||||
border: 1px solid $gray-85;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
border-radius: $corner-radius-full;
|
|
||||||
height: 100%;
|
|
||||||
background: $red-60;
|
|
||||||
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: $red-60;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-waiting {
|
|
||||||
color: $gray-50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: $gray-50;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: $unit;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $red-60;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-results {
|
.upload-results {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid $gray-85;
|
border: 1px solid $gray-85;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
import CloseButton from '../icons/CloseButton.svelte'
|
import CloseButton from '../icons/CloseButton.svelte'
|
||||||
import LoadingSpinner from './LoadingSpinner.svelte'
|
import LoadingSpinner from './LoadingSpinner.svelte'
|
||||||
import SmartImage from '../SmartImage.svelte'
|
import MediaGrid from './MediaGrid.svelte'
|
||||||
import { InfiniteLoader, LoaderState } from 'svelte-infinite'
|
import { InfiniteLoader, LoaderState } from 'svelte-infinite'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
|
|
@ -39,13 +39,22 @@
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let selectedMedia = $state<Media[]>([])
|
|
||||||
let media = $state<Media[]>([])
|
let media = $state<Media[]>([])
|
||||||
let isSaving = $state(false)
|
let isSaving = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let currentPage = $state(1)
|
let currentPage = $state(1)
|
||||||
let totalPages = $state(1)
|
let totalPages = $state(1)
|
||||||
let total = $state(0)
|
let total = $state(0)
|
||||||
|
|
||||||
|
// Media selection state
|
||||||
|
let selectedMediaIds = $state<Set<number>>(new Set(selectedIds))
|
||||||
|
|
||||||
|
// Derived selection values
|
||||||
|
const selectedMedia = $derived(
|
||||||
|
media.filter(m => selectedMediaIds.has(m.id))
|
||||||
|
)
|
||||||
|
const hasSelection = $derived(selectedMediaIds.size > 0)
|
||||||
|
const selectionCount = $derived(selectedMediaIds.size)
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
let filterType = $state<string>(fileType === 'all' ? 'all' : fileType)
|
let filterType = $state<string>(fileType === 'all' ? 'all' : fileType)
|
||||||
|
|
@ -83,8 +92,37 @@
|
||||||
confirmText || (showInAlbumMode ? 'Add Photos' : mode === 'single' ? 'Select' : 'Select Files')
|
confirmText || (showInAlbumMode ? 'Add Photos' : mode === 'single' ? 'Select' : 'Select Files')
|
||||||
)
|
)
|
||||||
|
|
||||||
const canConfirm = $derived(selectedMedia.length > 0 && (!showInAlbumMode || albumId))
|
const canConfirm = $derived(hasSelection && (!showInAlbumMode || albumId))
|
||||||
const mediaCount = $derived(selectedMedia.length)
|
const mediaCount = $derived(selectionCount)
|
||||||
|
|
||||||
|
// Selection methods
|
||||||
|
function toggleSelection(item: Media) {
|
||||||
|
if (mode === 'single') {
|
||||||
|
// Single selection mode - replace selection
|
||||||
|
selectedMediaIds = new Set([item.id])
|
||||||
|
} else {
|
||||||
|
// Multiple selection mode - toggle
|
||||||
|
if (selectedMediaIds.has(item.id)) {
|
||||||
|
selectedMediaIds.delete(item.id)
|
||||||
|
} else {
|
||||||
|
selectedMediaIds.add(item.id)
|
||||||
|
}
|
||||||
|
// Trigger reactivity
|
||||||
|
selectedMediaIds = new Set(selectedMediaIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedMediaIds = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedIds(): number[] {
|
||||||
|
return Array.from(selectedMediaIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelected(): Media[] {
|
||||||
|
return selectedMedia
|
||||||
|
}
|
||||||
|
|
||||||
const footerText = $derived(
|
const footerText = $derived(
|
||||||
showInAlbumMode && canConfirm
|
showInAlbumMode && canConfirm
|
||||||
|
|
@ -102,17 +140,13 @@
|
||||||
// Reset state when modal opens
|
// Reset state when modal opens
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
selectedMedia = []
|
selectedMediaIds.clear()
|
||||||
|
selectedMediaIds = new Set() // Trigger reactivity
|
||||||
// Don't clear media immediately - let new data replace old
|
// Don't clear media immediately - let new data replace old
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
isInitialLoad = true
|
isInitialLoad = true
|
||||||
loaderState.reset()
|
loaderState.reset()
|
||||||
loadMedia(1)
|
loadMedia(1)
|
||||||
|
|
||||||
// Initialize selected media from IDs if provided
|
|
||||||
if (selectedIds.length > 0) {
|
|
||||||
// Will be populated when media loads
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -150,10 +184,9 @@
|
||||||
// Initialize selected media from IDs when media loads
|
// Initialize selected media from IDs when media loads
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedIds.length > 0 && media.length > 0) {
|
if (selectedIds.length > 0 && media.length > 0) {
|
||||||
const preselected = media.filter((item) => selectedIds.includes(item.id))
|
// Re-select items that are in the current media list
|
||||||
if (preselected.length > 0) {
|
const availableIds = new Set(media.map(m => m.id))
|
||||||
selectedMedia = [...selectedMedia, ...preselected]
|
selectedMediaIds = new Set(selectedIds.filter(id => availableIds.has(id)))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -226,21 +259,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMediaClick(item: Media) {
|
function handleMediaClick(item: Media) {
|
||||||
if (mode === 'single') {
|
toggleSelection(item)
|
||||||
selectedMedia = [item]
|
|
||||||
} else {
|
|
||||||
const isSelected = selectedMedia.some((m) => m.id === item.id)
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
selectedMedia = selectedMedia.filter((m) => m.id !== item.id)
|
|
||||||
} else {
|
|
||||||
selectedMedia = [...selectedMedia, item]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSelected(item: Media): boolean {
|
|
||||||
return selectedMedia.some((m) => m.id === item.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
|
|
@ -254,7 +273,7 @@
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
if (!auth) return
|
if (!auth) return
|
||||||
|
|
||||||
const mediaIds = selectedMedia.map((m) => m.id)
|
const mediaIds = getSelectedIds()
|
||||||
|
|
||||||
const response = await fetch(`/api/albums/${albumId}/media`, {
|
const response = await fetch(`/api/albums/${albumId}/media`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -279,10 +298,11 @@
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular selection mode
|
// Regular selection mode
|
||||||
|
const selected = getSelected()
|
||||||
if (mode === 'single') {
|
if (mode === 'single') {
|
||||||
onSelect?.(selectedMedia[0])
|
onSelect?.(selected[0])
|
||||||
} else {
|
} else {
|
||||||
onSelect?.(selectedMedia)
|
onSelect?.(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClose()
|
handleClose()
|
||||||
|
|
@ -290,7 +310,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
selectedMedia = []
|
clearSelection()
|
||||||
error = ''
|
error = ''
|
||||||
isOpen = false
|
isOpen = false
|
||||||
onClose?.()
|
onClose?.()
|
||||||
|
|
@ -364,126 +384,16 @@
|
||||||
|
|
||||||
<!-- Media Grid -->
|
<!-- Media Grid -->
|
||||||
<div class="media-grid-container">
|
<div class="media-grid-container">
|
||||||
{#if isInitialLoad && media.length === 0}
|
<MediaGrid
|
||||||
<!-- Loading skeleton -->
|
{media}
|
||||||
<div class="media-grid">
|
selectedIds={selectedMediaIds}
|
||||||
{#each Array(12) as _, i}
|
onItemClick={handleMediaClick}
|
||||||
<div class="media-item skeleton" aria-hidden="true">
|
isLoading={isInitialLoad && media.length === 0}
|
||||||
<div class="media-thumbnail skeleton-bg"></div>
|
emptyMessage={fileType !== 'all'
|
||||||
</div>
|
? 'No media found. Try adjusting your filters or search'
|
||||||
{/each}
|
: 'No media found. Try adjusting your search or filters'}
|
||||||
</div>
|
mode="select"
|
||||||
{:else if media.length === 0 && currentPage === 1}
|
/>
|
||||||
<div class="empty-state">
|
|
||||||
<svg
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="3"
|
|
||||||
y="5"
|
|
||||||
width="18"
|
|
||||||
height="14"
|
|
||||||
rx="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
/>
|
|
||||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
|
|
||||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none" />
|
|
||||||
</svg>
|
|
||||||
<h3>No media found</h3>
|
|
||||||
<p>
|
|
||||||
{#if fileType !== 'all'}
|
|
||||||
Try adjusting your filters or search
|
|
||||||
{:else}
|
|
||||||
Try adjusting your search or filters
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="media-grid">
|
|
||||||
{#each media as item, i (item.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="media-item"
|
|
||||||
class:selected={isSelected(item)}
|
|
||||||
onclick={() => handleMediaClick(item)}
|
|
||||||
>
|
|
||||||
<!-- Thumbnail -->
|
|
||||||
<div
|
|
||||||
class="media-thumbnail"
|
|
||||||
class:is-svg={item.mimeType === 'image/svg+xml'}
|
|
||||||
style="background-color: {item.mimeType === 'image/svg+xml'
|
|
||||||
? 'transparent'
|
|
||||||
: item.dominantColor || '#f5f5f5'}"
|
|
||||||
>
|
|
||||||
{#if item.mimeType?.startsWith('image/')}
|
|
||||||
<SmartImage
|
|
||||||
media={item}
|
|
||||||
alt={item.filename}
|
|
||||||
loading={i < 8 ? 'eager' : 'lazy'}
|
|
||||||
class="media-image {item.mimeType === 'image/svg+xml' ? 'svg-image' : ''}"
|
|
||||||
containerWidth={150}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="media-placeholder">
|
|
||||||
<svg
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="5"
|
|
||||||
y="3"
|
|
||||||
width="14"
|
|
||||||
height="18"
|
|
||||||
rx="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M9 7H15M9 11H15M9 15H13"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Hover Overlay -->
|
|
||||||
<div class="hover-overlay"></div>
|
|
||||||
|
|
||||||
<!-- Selected Indicator -->
|
|
||||||
{#if isSelected(item)}
|
|
||||||
<div class="selected-indicator">
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M7 13l3 3 7-7"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Infinite Loader -->
|
<!-- Infinite Loader -->
|
||||||
<InfiniteLoader
|
<InfiniteLoader
|
||||||
|
|
@ -517,8 +427,7 @@
|
||||||
{#snippet noData()}
|
{#snippet noData()}
|
||||||
<!-- Empty snippet to hide "No more data" text -->
|
<!-- Empty snippet to hide "No more data" text -->
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</InfiniteLoader>
|
</InfiniteLoader>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|
@ -622,117 +531,6 @@
|
||||||
padding: 0 $unit-3x;
|
padding: 0 $unit-3x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: $unit-6x;
|
|
||||||
text-align: center;
|
|
||||||
color: $gray-40;
|
|
||||||
min-height: 400px;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
color: $gray-70;
|
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 $unit 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $gray-30;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: $gray-50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
||||||
gap: $unit-2x;
|
|
||||||
padding: $unit-3x 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-item {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
background: $gray-95;
|
|
||||||
border: none;
|
|
||||||
border-radius: $unit-2x;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&:hover .hover-overlay {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-thumbnail {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
|
|
||||||
:global(.media-image) {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
animation: fadeIn 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-svg {
|
|
||||||
padding: $unit-2x;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background-color: $gray-95 !important;
|
|
||||||
|
|
||||||
:global(.svg-image) {
|
|
||||||
object-fit: contain !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: $gray-60;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: $unit;
|
|
||||||
right: $unit;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
background: $blue-50;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -799,48 +597,6 @@
|
||||||
font-size: 13px !important;
|
font-size: 13px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skeleton loader styles
|
|
||||||
.skeleton {
|
|
||||||
pointer-events: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-bg {
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-image-placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: $unit;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $gray-50;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the infinite scroll intersection target
|
// Hide the infinite scroll intersection target
|
||||||
:global(.infinite-intersection-target) {
|
:global(.infinite-intersection-target) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue