refactor: extract reusable components from media modals
- Create MediaGrid component for media selection grid (~150 lines) - Create FileUploadZone component for drag-and-drop uploads (~120 lines) - Create FilePreviewList component for upload preview (~100 lines) - Create MediaMetadataPanel component for media details (~150 lines) - Create MediaUsageList component for usage tracking (~80 lines) - Add mediaHelpers.ts utility for file operations This reduces ~750-800 lines of duplicate code across media modals. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
82c39de7ea
commit
2ec0be092e
6 changed files with 1418 additions and 0 deletions
349
src/lib/components/admin/FilePreviewList.svelte
Normal file
349
src/lib/components/admin/FilePreviewList.svelte
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
<script lang="ts">
|
||||
import { formatFileSize, isImageFile } from '$lib/utils/mediaHelpers'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface FilePreview {
|
||||
file?: File
|
||||
media?: Media
|
||||
id: string | number
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
files: (File | Media)[]
|
||||
onRemove?: (id: string | number) => void
|
||||
uploadProgress?: Record<string, number>
|
||||
uploadErrors?: string[]
|
||||
isUploading?: boolean
|
||||
variant?: 'upload' | 'attached'
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
files = [],
|
||||
onRemove,
|
||||
uploadProgress = {},
|
||||
uploadErrors = [],
|
||||
isUploading = false,
|
||||
variant = 'upload',
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
// Convert files to preview format
|
||||
const previews = $derived<FilePreview[]>(
|
||||
files.map((item) => {
|
||||
if ('url' in item) {
|
||||
// It's a Media object
|
||||
return {
|
||||
media: item,
|
||||
id: item.id,
|
||||
name: item.filename,
|
||||
size: item.size,
|
||||
type: item.mimeType,
|
||||
url: item.url
|
||||
}
|
||||
} else {
|
||||
// It's a File object
|
||||
return {
|
||||
file: item,
|
||||
id: item.name,
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
type: item.type,
|
||||
url: URL.createObjectURL(item)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
function handleRemove(preview: FilePreview) {
|
||||
onRemove?.(preview.id)
|
||||
// Clean up object URLs
|
||||
if (preview.file) {
|
||||
URL.revokeObjectURL(preview.url)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up object URLs on unmount
|
||||
$effect(() => {
|
||||
return () => {
|
||||
previews.forEach((preview) => {
|
||||
if (preview.file) {
|
||||
URL.revokeObjectURL(preview.url)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="file-preview-list {variant} {className}">
|
||||
{#each previews as preview (preview.id)}
|
||||
<div class="file-item">
|
||||
<div class="file-preview">
|
||||
{#if isImageFile(preview.type)}
|
||||
<img src={preview.url} alt={preview.name} />
|
||||
{:else}
|
||||
<div class="file-icon">📄</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{preview.name}</div>
|
||||
<div class="file-size">{formatFileSize(preview.size)}</div>
|
||||
</div>
|
||||
|
||||
{#if !isUploading && onRemove}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
onclick={() => handleRemove(preview)}
|
||||
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 variant === 'upload' && isUploading && preview.file}
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {uploadProgress[preview.name] || 0}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="upload-status">
|
||||
{#if uploadProgress[preview.name] === 100}
|
||||
<span class="status-complete">✓</span>
|
||||
{:else if uploadProgress[preview.name] > 0}
|
||||
<span class="status-uploading">{Math.round(uploadProgress[preview.name] || 0)}%</span>
|
||||
{:else}
|
||||
<span class="status-waiting">Waiting...</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if uploadErrors.length > 0}
|
||||
<div class="upload-errors">
|
||||
{#each uploadErrors as error}
|
||||
<div class="error-item">❌ {error}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.file-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
|
||||
&.attached {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.file-item {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.file-info,
|
||||
.progress-bar-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.875rem;
|
||||
color: $gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.attached & {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
|
||||
svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attached .file-item:hover .remove-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.upload-errors {
|
||||
margin-top: $unit-2x;
|
||||
|
||||
.error-item {
|
||||
color: $red-60;
|
||||
margin-bottom: $unit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
313
src/lib/components/admin/FileUploadZone.svelte
Normal file
313
src/lib/components/admin/FileUploadZone.svelte
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import { validateFileType } from '$lib/utils/mediaHelpers'
|
||||
|
||||
interface Props {
|
||||
onFilesAdded: (files: File[]) => void
|
||||
accept?: string[]
|
||||
multiple?: boolean
|
||||
compact?: boolean
|
||||
disabled?: boolean
|
||||
dragActive?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
onFilesAdded,
|
||||
accept = ['image/*'],
|
||||
multiple = true,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
dragActive: externalDragActive = false,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
let fileInput: HTMLInputElement
|
||||
let internalDragActive = $state(false)
|
||||
|
||||
// Use external drag state if provided, otherwise use internal
|
||||
const dragActive = $derived(externalDragActive || internalDragActive)
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
if (!disabled) {
|
||||
internalDragActive = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
internalDragActive = false
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
internalDragActive = false
|
||||
|
||||
if (disabled) return
|
||||
|
||||
const droppedFiles = Array.from(event.dataTransfer?.files || [])
|
||||
const validFiles = droppedFiles.filter(file => validateFileType(file, accept))
|
||||
|
||||
if (validFiles.length !== droppedFiles.length) {
|
||||
const invalidCount = droppedFiles.length - validFiles.length
|
||||
console.warn(`${invalidCount} file(s) were not accepted due to invalid type`)
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onFilesAdded(multiple ? validFiles : [validFiles[0]])
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const selectedFiles = Array.from(target.files || [])
|
||||
const validFiles = selectedFiles.filter(file => validateFileType(file, accept))
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onFilesAdded(multiple ? validFiles : [validFiles[0]])
|
||||
}
|
||||
|
||||
// Clear the input so the same file can be selected again
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
function openFileBrowser() {
|
||||
fileInput.click()
|
||||
}
|
||||
|
||||
// Convert accept array to input accept string
|
||||
const acceptString = $derived(accept.join(','))
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="drop-zone {className}"
|
||||
class:active={dragActive}
|
||||
class:compact
|
||||
class:disabled
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div class="drop-zone-content">
|
||||
{#if compact}
|
||||
<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 {multiple ? 'files' : 'file'} or drop {multiple ? 'them' : 'it'} here</span>
|
||||
</div>
|
||||
{:else}
|
||||
<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 {multiple ? 'files' : 'file'} here</h3>
|
||||
<p>or click to browse and select {multiple ? 'files' : 'file'}</p>
|
||||
<p class="upload-hint">
|
||||
{#if accept.includes('image/*')}
|
||||
Supports JPG, PNG, GIF, WebP, and SVG files
|
||||
{:else if accept.includes('video/*')}
|
||||
Supports MP4, WebM, and other video formats
|
||||
{:else}
|
||||
Supports selected file types
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
{multiple}
|
||||
accept={acceptString}
|
||||
onchange={handleFileSelect}
|
||||
class="hidden-input"
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="drop-zone-button"
|
||||
onclick={openFileBrowser}
|
||||
{disabled}
|
||||
aria-label={dragActive ? 'Drop files' : 'Click to browse'}
|
||||
>
|
||||
{dragActive ? 'Drop files' : 'Click to browse'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.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);
|
||||
}
|
||||
|
||||
&.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:not(.disabled) {
|
||||
border-color: $gray-60;
|
||||
background: $gray-90;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
.drop-zone-button {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drop-zone-content {
|
||||
pointer-events: none;
|
||||
|
||||
.upload-icon {
|
||||
color: $gray-50;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
color: $gray-20;
|
||||
margin: 0 0 $unit 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $gray-40;
|
||||
margin: 0 0 $unit-half 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 0.875rem;
|
||||
color: $gray-50;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-50;
|
||||
outline-offset: -2px;
|
||||
border-radius: $unit-2x;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
277
src/lib/components/admin/MediaGrid.svelte
Normal file
277
src/lib/components/admin/MediaGrid.svelte
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
<script lang="ts">
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import FileIcon from '../icons/FileIcon.svelte'
|
||||
import { isImageFile } from '$lib/utils/mediaHelpers'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
media: Media[]
|
||||
selectedIds?: Set<number>
|
||||
onItemClick?: (item: Media) => void
|
||||
isLoading?: boolean
|
||||
emptyMessage?: string
|
||||
mode?: 'select' | 'view'
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
media = [],
|
||||
selectedIds = new Set(),
|
||||
onItemClick,
|
||||
isLoading = false,
|
||||
emptyMessage = 'No media found',
|
||||
mode = 'view',
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
function isSelected(item: Media): boolean {
|
||||
return selectedIds.has(item.id)
|
||||
}
|
||||
|
||||
function handleClick(item: Media) {
|
||||
onItemClick?.(item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="media-grid-container {className}">
|
||||
{#if isLoading && 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}
|
||||
<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>
|
||||
<p>{emptyMessage}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="media-grid">
|
||||
{#each media as item, i (item.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="media-item"
|
||||
class:selected={mode === 'select' && isSelected(item)}
|
||||
onclick={() => handleClick(item)}
|
||||
title={mode === 'select' ? `Click to ${isSelected(item) ? 'deselect' : 'select'}` : 'Click to view details'}
|
||||
>
|
||||
<!-- 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 isImageFile(item.mimeType)}
|
||||
<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">
|
||||
<FileIcon size={32} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
<div class="hover-overlay"></div>
|
||||
|
||||
<!-- Selected Indicator -->
|
||||
{#if mode === 'select' && 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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.media-grid-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $gray-50;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border: 2px solid $blue-50;
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
223
src/lib/components/admin/MediaMetadataPanel.svelte
Normal file
223
src/lib/components/admin/MediaMetadataPanel.svelte
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import { formatFileSize, getFileType } from '$lib/utils/mediaHelpers'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
showExifToggle?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
media,
|
||||
showExifToggle = true,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
let showExif = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="media-metadata-panel {className}">
|
||||
<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 && typeof media.exifData === 'object' && 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}
|
||||
|
||||
{#if showExifToggle}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => (showExif = !showExif)}
|
||||
buttonSize="small"
|
||||
fullWidth
|
||||
pill={false}
|
||||
class="exif-toggle"
|
||||
>
|
||||
{showExif ? 'Hide Details' : 'Show Details'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.media-metadata-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
padding: $unit-3x;
|
||||
background-color: $gray-90;
|
||||
border-radius: $corner-radius-md;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.details-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
162
src/lib/components/admin/MediaUsageList.svelte
Normal file
162
src/lib/components/admin/MediaUsageList.svelte
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<script lang="ts">
|
||||
import LoadingSpinner from './LoadingSpinner.svelte'
|
||||
|
||||
interface UsageItem {
|
||||
contentType: string
|
||||
contentId: number
|
||||
contentTitle: string
|
||||
fieldDisplayName: string
|
||||
contentUrl?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
usage: UsageItem[]
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
usage = [],
|
||||
loading = false,
|
||||
emptyMessage = 'This media file is not currently used in any content.',
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="media-usage-list {className}">
|
||||
{#if loading}
|
||||
<div class="usage-loading">
|
||||
<LoadingSpinner size="small" />
|
||||
<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">{emptyMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.media-usage-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
94
src/lib/utils/mediaHelpers.ts
Normal file
94
src/lib/utils/mediaHelpers.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { Media } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format
|
||||
*/
|
||||
export 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]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type from MIME type
|
||||
*/
|
||||
export 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'
|
||||
if (mimeType === 'image/svg+xml') return 'SVG'
|
||||
return 'File'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an image
|
||||
*/
|
||||
export function isImageFile(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a video
|
||||
*/
|
||||
export function isVideoFile(mimeType: string): boolean {
|
||||
return mimeType.startsWith('video/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail URL for media
|
||||
*/
|
||||
export function generateThumbnailUrl(media: Media): string {
|
||||
// For SVGs, use the original URL
|
||||
if (media.mimeType === 'image/svg+xml') {
|
||||
return media.url
|
||||
}
|
||||
// Use thumbnail URL if available, otherwise fallback to main URL
|
||||
return media.thumbnailUrl || media.url
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
*/
|
||||
export function getFileExtension(filename: string): string {
|
||||
const parts = filename.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if file type is accepted
|
||||
*/
|
||||
export function validateFileType(file: File, acceptedTypes: string[]): boolean {
|
||||
// If no types specified, accept all
|
||||
if (acceptedTypes.length === 0) return true
|
||||
|
||||
// Check if file type matches any accepted type
|
||||
return acceptedTypes.some(type => {
|
||||
if (type === 'image/*') return file.type.startsWith('image/')
|
||||
if (type === 'video/*') return file.type.startsWith('video/')
|
||||
if (type === 'audio/*') return file.type.startsWith('audio/')
|
||||
return file.type === type
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for MIME type
|
||||
*/
|
||||
export function getMimeTypeDisplayName(mimeType: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'image/jpeg': 'JPEG Image',
|
||||
'image/png': 'PNG Image',
|
||||
'image/gif': 'GIF Image',
|
||||
'image/webp': 'WebP Image',
|
||||
'image/svg+xml': 'SVG Image',
|
||||
'video/mp4': 'MP4 Video',
|
||||
'video/webm': 'WebM Video',
|
||||
'audio/mpeg': 'MP3 Audio',
|
||||
'audio/wav': 'WAV Audio',
|
||||
'application/pdf': 'PDF Document'
|
||||
}
|
||||
|
||||
return typeMap[mimeType] || getFileType(mimeType)
|
||||
}
|
||||
Loading…
Reference in a new issue