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:
Justin Edmund 2025-06-26 13:46:52 -04:00
parent 82c39de7ea
commit 2ec0be092e
6 changed files with 1418 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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)
}