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:
Justin Edmund 2025-06-26 13:47:10 -04:00
parent 2ec0be092e
commit 64b5a8e73c
3 changed files with 109 additions and 1131 deletions

View file

@ -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) {

View file

@ -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,24 +50,20 @@
files = [...files, ...imageFiles]
}
function removeFile(index: number) {
files = files.filter((_, i) => i !== index)
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
const fileName = files[index]?.name
if (fileName && uploadProgress[fileName]) {
const { [fileName]: removed, ...rest } = uploadProgress
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()}
<FileUploadZone
onFilesAdded={handleFilesAdded}
accept={['image/*']}
multiple={true}
compact={files.length > 0}
disabled={isUploading}
>
{dragActive ? 'Drop files' : 'Click to browse'}
</button>
</div>
{dragActive}
/>
<!-- Upload Results -->
{#if successCount > 0 || uploadErrors.length > 0}
<div class="upload-results">
{#if successCount > 0}
<div class="upload-results">
<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}
{#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}
<!-- 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;

View file

@ -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,7 +39,6 @@
}: Props = $props()
// State
let selectedMedia = $state<Media[]>([])
let media = $state<Media[]>([])
let isSaving = $state(false)
let error = $state('')
@ -47,6 +46,16 @@
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)
let photographyFilter = $state<string>('all')
@ -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"
<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"
/>
<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 -->
<InfiniteLoader
@ -518,7 +428,6 @@
<!-- Empty snippet to hide "No more data" text -->
{/snippet}
</InfiniteLoader>
{/if}
</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) {