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 FileIcon from '$components/icons/FileIcon.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 { toast } from '$lib/stores/toast'
|
||||
import { formatFileSize, getFileType } from '$lib/utils/mediaHelpers'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -45,15 +48,11 @@
|
|||
let loadingAlbums = $state(false)
|
||||
let showAlbumSelector = $state(false)
|
||||
|
||||
// EXIF toggle state
|
||||
let showExif = $state(false)
|
||||
|
||||
// Initialize form when media changes
|
||||
$effect(() => {
|
||||
if (media) {
|
||||
description = media.description || ''
|
||||
isPhotography = media.isPhotography || false
|
||||
showExif = false
|
||||
loadUsage()
|
||||
// Only load albums for images
|
||||
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>
|
||||
|
||||
{#if media}
|
||||
|
|
@ -262,120 +246,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<div class="file-info">
|
||||
<div class="info-grid">
|
||||
<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>
|
||||
<!-- Media Metadata Panel -->
|
||||
<MediaMetadataPanel {media} showExifToggle={true} />
|
||||
|
||||
<div class="pane-body-content">
|
||||
<!-- Photography Toggle -->
|
||||
|
|
@ -421,45 +293,7 @@
|
|||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if 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>
|
||||
<MediaUsageList {usage} loading={loadingUsage} />
|
||||
|
||||
<!-- Albums list -->
|
||||
{#if albums.length > 0}
|
||||
|
|
@ -611,86 +445,6 @@
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -816,98 +570,6 @@
|
|||
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
|
||||
|
|
@ -973,14 +635,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<script lang="ts">
|
||||
import Modal from './Modal.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import FileUploadZone from './FileUploadZone.svelte'
|
||||
import FilePreviewList from './FilePreviewList.svelte'
|
||||
import { formatFileSize } from '$lib/utils/mediaHelpers'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
|
|
@ -16,7 +19,6 @@
|
|||
let uploadProgress = $state<Record<string, number>>({})
|
||||
let uploadErrors = $state<string[]>([])
|
||||
let successCount = $state(0)
|
||||
let fileInput: HTMLInputElement
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
$effect(() => {
|
||||
|
|
@ -30,28 +32,8 @@
|
|||
}
|
||||
})
|
||||
|
||||
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 handleFilesAdded(newFiles: File[]) {
|
||||
addFiles(newFiles)
|
||||
}
|
||||
|
||||
function addFiles(newFiles: File[]) {
|
||||
|
|
@ -68,23 +50,19 @@
|
|||
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 removeFile(id: string | number) {
|
||||
// For files, the id is the filename
|
||||
const fileToRemove = files.find(f => f.name === id)
|
||||
if (fileToRemove) {
|
||||
files = files.filter(f => f.name !== id)
|
||||
// Clear any related upload progress
|
||||
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() {
|
||||
if (files.length === 0) return
|
||||
|
|
@ -160,209 +138,38 @@
|
|||
<div class="modal-inner-content">
|
||||
<!-- File List (shown above drop zone when files are selected) -->
|
||||
{#if files.length > 0}
|
||||
<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>
|
||||
</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>
|
||||
<FilePreviewList
|
||||
{files}
|
||||
onRemove={removeFile}
|
||||
{uploadProgress}
|
||||
{isUploading}
|
||||
variant="upload"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Drop Zone (compact when files are selected) -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
class:active={dragActive}
|
||||
class:has-files={files.length > 0}
|
||||
class:compact={files.length > 0}
|
||||
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>
|
||||
<FileUploadZone
|
||||
onFilesAdded={handleFilesAdded}
|
||||
accept={['image/*']}
|
||||
multiple={true}
|
||||
compact={files.length > 0}
|
||||
disabled={isUploading}
|
||||
{dragActive}
|
||||
/>
|
||||
|
||||
<!-- Upload Results -->
|
||||
{#if successCount > 0 || uploadErrors.length > 0}
|
||||
{#if successCount > 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>Closing modal...</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 class="success-message">
|
||||
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
|
||||
{#if successCount === files.length && uploadErrors.length === 0}
|
||||
<br /><small>Closing modal...</small>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error messages are now handled in FilePreviewList -->
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer with actions -->
|
||||
|
|
@ -428,245 +235,6 @@
|
|||
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 {
|
||||
background: white;
|
||||
border: 1px solid $gray-85;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import Button from './Button.svelte'
|
||||
import CloseButton from '../icons/CloseButton.svelte'
|
||||
import LoadingSpinner from './LoadingSpinner.svelte'
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import MediaGrid from './MediaGrid.svelte'
|
||||
import { InfiniteLoader, LoaderState } from 'svelte-infinite'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
|
|
@ -39,13 +39,22 @@
|
|||
}: Props = $props()
|
||||
|
||||
// State
|
||||
let selectedMedia = $state<Media[]>([])
|
||||
let media = $state<Media[]>([])
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let currentPage = $state(1)
|
||||
let totalPages = $state(1)
|
||||
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
|
||||
let filterType = $state<string>(fileType === 'all' ? 'all' : fileType)
|
||||
|
|
@ -83,8 +92,37 @@
|
|||
confirmText || (showInAlbumMode ? 'Add Photos' : mode === 'single' ? 'Select' : 'Select Files')
|
||||
)
|
||||
|
||||
const canConfirm = $derived(selectedMedia.length > 0 && (!showInAlbumMode || albumId))
|
||||
const mediaCount = $derived(selectedMedia.length)
|
||||
const canConfirm = $derived(hasSelection && (!showInAlbumMode || albumId))
|
||||
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(
|
||||
showInAlbumMode && canConfirm
|
||||
|
|
@ -102,17 +140,13 @@
|
|||
// Reset state when modal opens
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
selectedMedia = []
|
||||
selectedMediaIds.clear()
|
||||
selectedMediaIds = new Set() // Trigger reactivity
|
||||
// Don't clear media immediately - let new data replace old
|
||||
currentPage = 1
|
||||
isInitialLoad = true
|
||||
loaderState.reset()
|
||||
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
|
||||
$effect(() => {
|
||||
if (selectedIds.length > 0 && media.length > 0) {
|
||||
const preselected = media.filter((item) => selectedIds.includes(item.id))
|
||||
if (preselected.length > 0) {
|
||||
selectedMedia = [...selectedMedia, ...preselected]
|
||||
}
|
||||
// Re-select items that are in the current media list
|
||||
const availableIds = new Set(media.map(m => m.id))
|
||||
selectedMediaIds = new Set(selectedIds.filter(id => availableIds.has(id)))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -226,21 +259,7 @@
|
|||
}
|
||||
|
||||
function handleMediaClick(item: Media) {
|
||||
if (mode === 'single') {
|
||||
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)
|
||||
toggleSelection(item)
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
|
|
@ -254,7 +273,7 @@
|
|||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) return
|
||||
|
||||
const mediaIds = selectedMedia.map((m) => m.id)
|
||||
const mediaIds = getSelectedIds()
|
||||
|
||||
const response = await fetch(`/api/albums/${albumId}/media`, {
|
||||
method: 'POST',
|
||||
|
|
@ -279,10 +298,11 @@
|
|||
}
|
||||
} else {
|
||||
// Regular selection mode
|
||||
const selected = getSelected()
|
||||
if (mode === 'single') {
|
||||
onSelect?.(selectedMedia[0])
|
||||
onSelect?.(selected[0])
|
||||
} else {
|
||||
onSelect?.(selectedMedia)
|
||||
onSelect?.(selected)
|
||||
}
|
||||
|
||||
handleClose()
|
||||
|
|
@ -290,7 +310,7 @@
|
|||
}
|
||||
|
||||
function handleClose() {
|
||||
selectedMedia = []
|
||||
clearSelection()
|
||||
error = ''
|
||||
isOpen = false
|
||||
onClose?.()
|
||||
|
|
@ -364,126 +384,16 @@
|
|||
|
||||
<!-- Media Grid -->
|
||||
<div class="media-grid-container">
|
||||
{#if isInitialLoad && media.length === 0}
|
||||
<!-- Loading skeleton -->
|
||||
<div class="media-grid">
|
||||
{#each Array(12) as _, i}
|
||||
<div class="media-item skeleton" aria-hidden="true">
|
||||
<div class="media-thumbnail skeleton-bg"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{: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>
|
||||
<MediaGrid
|
||||
{media}
|
||||
selectedIds={selectedMediaIds}
|
||||
onItemClick={handleMediaClick}
|
||||
isLoading={isInitialLoad && media.length === 0}
|
||||
emptyMessage={fileType !== 'all'
|
||||
? 'No media found. Try adjusting your filters or search'
|
||||
: 'No media found. Try adjusting your search or filters'}
|
||||
mode="select"
|
||||
/>
|
||||
|
||||
<!-- Infinite Loader -->
|
||||
<InfiniteLoader
|
||||
|
|
@ -517,8 +427,7 @@
|
|||
{#snippet noData()}
|
||||
<!-- Empty snippet to hide "No more data" text -->
|
||||
{/snippet}
|
||||
</InfiniteLoader>
|
||||
{/if}
|
||||
</InfiniteLoader>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
|
|
@ -622,117 +531,6 @@
|
|||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
@ -799,48 +597,6 @@
|
|||
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
|
||||
:global(.infinite-intersection-target) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue