jedmund-svelte/src/lib/components/admin/MediaInput.svelte

394 lines
No EOL
8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import Button from './Button.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import type { Media } from '@prisma/client'
interface Props {
label: string
value?: Media | Media[] | null
mode: 'single' | 'multiple'
fileType?: 'image' | 'video' | 'all'
placeholder?: string
required?: boolean
error?: string
}
let {
label,
value = $bindable(),
mode,
fileType = 'all',
placeholder = mode === 'single' ? 'No file selected' : 'No files selected',
required = false,
error
}: Props = $props()
let showModal = $state(false)
function handleMediaSelect(media: Media | Media[]) {
value = media
showModal = false
}
function handleClear() {
if (mode === 'single') {
value = null
} else {
value = []
}
}
function openModal() {
showModal = true
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// Computed properties
const hasValue = $derived(
mode === 'single'
? value !== null && value !== undefined
: Array.isArray(value) && value.length > 0
)
const displayText = $derived(
!hasValue
? placeholder
: mode === 'single' && value && !Array.isArray(value)
? value.filename
: mode === 'multiple' && Array.isArray(value)
? value.length === 1
? `${value.length} file selected`
: `${value.length} files selected`
: placeholder
)
const selectedIds = $derived(
!hasValue
? []
: mode === 'single' && value && !Array.isArray(value)
? [value.id]
: mode === 'multiple' && Array.isArray(value)
? value.map(item => item.id)
: []
)
const modalTitle = $derived(
mode === 'single' ? `Select ${fileType === 'image' ? 'Image' : 'Media'}` : `Select ${fileType === 'image' ? 'Images' : 'Media'}`
)
const confirmText = $derived(
mode === 'single' ? 'Select' : 'Select Files'
)
</script>
<div class="media-input">
<label class="input-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
<!-- Selected Media Preview -->
{#if hasValue}
<div class="selected-media">
{#if mode === 'single' && value && !Array.isArray(value)}
<div class="media-preview single">
<div class="media-thumbnail">
{#if value.thumbnailUrl}
<img src={value.thumbnailUrl} alt={value.filename} />
{:else}
<div class="media-placeholder">
<svg width="24" height="24" 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>
</div>
{/if}
</div>
<div class="media-info">
<p class="filename">{value.filename}</p>
<p class="file-meta">
{formatFileSize(value.size)}
{#if value.width && value.height}
{value.width}×{value.height}
{/if}
</p>
</div>
</div>
{:else if mode === 'multiple' && Array.isArray(value) && value.length > 0}
<div class="media-preview multiple">
<div class="media-grid">
{#each value.slice(0, 4) as item}
<div class="media-thumbnail">
{#if item.thumbnailUrl}
<img src={item.thumbnailUrl} alt={item.filename} />
{:else}
<div class="media-placeholder">
<svg width="16" height="16" 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>
</div>
{/if}
</div>
{/each}
{#if value.length > 4}
<div class="media-thumbnail overflow">
<div class="overflow-indicator">
+{value.length - 4}
</div>
</div>
{/if}
</div>
<p class="selection-summary">
{value.length} file{value.length !== 1 ? 's' : ''} selected
</p>
</div>
{/if}
</div>
{/if}
<!-- Input Field -->
<div class="input-field" class:has-error={error}>
<input
type="text"
readonly
value={displayText}
class="media-input-field"
class:placeholder={!hasValue}
/>
<div class="input-actions">
<Button variant="ghost" onclick={openModal}>
Browse
</Button>
{#if hasValue}
<Button variant="ghost" onclick={handleClear} aria-label="Clear selection">
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</Button>
{/if}
</div>
</div>
<!-- Error Message -->
{#if error}
<p class="error-message">{error}</p>
{/if}
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={showModal}
{mode}
{fileType}
{selectedIds}
title={modalTitle}
confirmText={confirmText}
onselect={handleMediaSelect}
/>
</div>
<style lang="scss">
.media-input {
display: flex;
flex-direction: column;
gap: $unit;
}
.input-label {
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
.required {
color: $red-60;
margin-left: $unit-half;
}
}
.selected-media {
padding: $unit-2x;
background-color: $grey-95;
border-radius: $card-corner-radius;
border: 1px solid $grey-85;
}
.media-preview {
&.single {
display: flex;
gap: $unit-2x;
align-items: flex-start;
}
&.multiple {
display: flex;
flex-direction: column;
gap: $unit;
}
}
.media-thumbnail {
width: 60px;
height: 60px;
border-radius: calc($card-corner-radius - 2px);
overflow: hidden;
background-color: $grey-90;
flex-shrink: 0;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&.overflow {
display: flex;
align-items: center;
justify-content: center;
background-color: $grey-80;
color: $grey-30;
font-size: 0.75rem;
font-weight: 600;
}
}
.media-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: $grey-60;
}
.media-info {
flex: 1;
min-width: 0;
.filename {
margin: 0 0 $unit-half 0;
font-size: 0.875rem;
font-weight: 500;
color: $grey-10;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
margin: 0;
font-size: 0.75rem;
color: $grey-40;
}
}
.media-grid {
display: flex;
gap: $unit;
margin-bottom: $unit;
}
.selection-summary {
margin: 0;
font-size: 0.875rem;
color: $grey-30;
font-weight: 500;
}
.input-field {
position: relative;
display: flex;
align-items: center;
border: 1px solid $grey-80;
border-radius: $card-corner-radius;
background-color: white;
transition: border-color 0.2s ease;
&:focus-within {
border-color: $blue-60;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&.has-error {
border-color: $red-60;
&:focus-within {
border-color: $red-60;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
}
}
.media-input-field {
flex: 1;
padding: $unit $unit-2x;
border: none;
background: transparent;
font-size: 0.875rem;
color: $grey-10;
&:focus {
outline: none;
}
&.placeholder {
color: $grey-50;
}
&[readonly] {
cursor: pointer;
}
}
.input-actions {
display: flex;
align-items: center;
padding-right: $unit-half;
gap: $unit-half;
}
.error-message {
margin: 0;
font-size: 0.75rem;
color: $red-60;
}
// Responsive adjustments
@media (max-width: 640px) {
.media-preview.single {
flex-direction: column;
}
.media-thumbnail {
width: 80px;
height: 80px;
}
.media-grid {
flex-wrap: wrap;
}
}
</style>