859 lines
18 KiB
Svelte
859 lines
18 KiB
Svelte
<script lang="ts">
|
||
import type { Media } from '@prisma/client'
|
||
import Button from './Button.svelte'
|
||
import Input from './Input.svelte'
|
||
import SmartImage from '../SmartImage.svelte'
|
||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||
import { authenticatedFetch } from '$lib/admin-auth'
|
||
import RefreshIcon from '$icons/refresh.svg?component'
|
||
|
||
interface Props {
|
||
label: string
|
||
value?: Media | null
|
||
onUpload: (media: Media) => void
|
||
onRemove?: () => void
|
||
aspectRatio?: string // e.g., "16:9", "1:1"
|
||
required?: boolean
|
||
error?: string
|
||
allowAltText?: boolean
|
||
maxFileSize?: number // MB limit
|
||
placeholder?: string
|
||
helpText?: string
|
||
showBrowseLibrary?: boolean // Show secondary "Browse Library" button
|
||
compact?: boolean // Use compact layout with smaller preview and side-by-side alt text
|
||
}
|
||
|
||
let {
|
||
label,
|
||
value = $bindable(),
|
||
onUpload,
|
||
onRemove,
|
||
aspectRatio,
|
||
required = false,
|
||
error,
|
||
allowAltText = true,
|
||
maxFileSize = 10,
|
||
placeholder = 'Drag and drop an image here, or click to browse',
|
||
helpText,
|
||
showBrowseLibrary = false,
|
||
compact = false
|
||
}: Props = $props()
|
||
|
||
// State
|
||
let isUploading = $state(false)
|
||
let uploadProgress = $state(0)
|
||
let uploadError = $state<string | null>(null)
|
||
let isDragOver = $state(false)
|
||
let fileInputElement: HTMLInputElement
|
||
let altTextValue = $state(value?.altText || '')
|
||
let descriptionValue = $state(value?.description || '')
|
||
let isMediaLibraryOpen = $state(false)
|
||
|
||
// Computed properties
|
||
const hasValue = $derived(!!value)
|
||
const aspectRatioStyle = $derived(() => {
|
||
if (!aspectRatio) return ''
|
||
const [w, h] = aspectRatio.split(':').map(Number)
|
||
const ratio = (h / w) * 100
|
||
return `aspect-ratio: ${w}/${h}; padding-bottom: ${ratio}%;`
|
||
})
|
||
|
||
// File validation
|
||
function validateFile(file: File): string | null {
|
||
// Check file type
|
||
if (!file.type.startsWith('image/')) {
|
||
return 'Please select an image file'
|
||
}
|
||
|
||
// Check file size
|
||
const sizeMB = file.size / 1024 / 1024
|
||
if (sizeMB > maxFileSize) {
|
||
return `File size must be less than ${maxFileSize}MB`
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// Upload file to server
|
||
async function uploadFile(file: File): Promise<Media> {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
if (allowAltText && altTextValue.trim()) {
|
||
formData.append('altText', altTextValue.trim())
|
||
}
|
||
|
||
if (allowAltText && descriptionValue.trim()) {
|
||
formData.append('description', descriptionValue.trim())
|
||
}
|
||
|
||
const response = await authenticatedFetch('/api/media/upload', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json()
|
||
throw new Error(errorData.error || 'Upload failed')
|
||
}
|
||
|
||
return await response.json()
|
||
}
|
||
|
||
// Handle file selection/drop
|
||
async function handleFiles(files: FileList) {
|
||
if (files.length === 0) return
|
||
|
||
const file = files[0]
|
||
const validationError = validateFile(file)
|
||
|
||
if (validationError) {
|
||
uploadError = validationError
|
||
return
|
||
}
|
||
|
||
uploadError = null
|
||
isUploading = true
|
||
uploadProgress = 0
|
||
|
||
try {
|
||
// Simulate progress for user feedback
|
||
const progressInterval = setInterval(() => {
|
||
if (uploadProgress < 90) {
|
||
uploadProgress += Math.random() * 10
|
||
}
|
||
}, 100)
|
||
|
||
const uploadedMedia = await uploadFile(file)
|
||
|
||
clearInterval(progressInterval)
|
||
uploadProgress = 100
|
||
|
||
// Brief delay to show completion
|
||
setTimeout(() => {
|
||
value = uploadedMedia
|
||
altTextValue = uploadedMedia.altText || ''
|
||
descriptionValue = uploadedMedia.description || ''
|
||
onUpload(uploadedMedia)
|
||
isUploading = false
|
||
uploadProgress = 0
|
||
}, 500)
|
||
} catch (err) {
|
||
isUploading = false
|
||
uploadProgress = 0
|
||
uploadError = err instanceof Error ? err.message : 'Upload failed'
|
||
}
|
||
}
|
||
|
||
// Drag and drop handlers
|
||
function handleDragOver(event: DragEvent) {
|
||
event.preventDefault()
|
||
isDragOver = true
|
||
}
|
||
|
||
function handleDragLeave(event: DragEvent) {
|
||
event.preventDefault()
|
||
isDragOver = false
|
||
}
|
||
|
||
function handleDrop(event: DragEvent) {
|
||
event.preventDefault()
|
||
isDragOver = false
|
||
|
||
const files = event.dataTransfer?.files
|
||
if (files) {
|
||
handleFiles(files)
|
||
}
|
||
}
|
||
|
||
// Click to browse handler
|
||
function handleBrowseClick() {
|
||
fileInputElement?.click()
|
||
}
|
||
|
||
function handleFileInputChange(event: Event) {
|
||
const target = event.target as HTMLInputElement
|
||
if (target.files) {
|
||
handleFiles(target.files)
|
||
}
|
||
}
|
||
|
||
// Remove uploaded image
|
||
function handleRemove() {
|
||
value = null
|
||
altTextValue = ''
|
||
descriptionValue = ''
|
||
uploadError = null
|
||
onRemove?.()
|
||
}
|
||
|
||
// Update alt text on server
|
||
async function handleAltTextChange() {
|
||
if (!value) return
|
||
|
||
try {
|
||
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
altText: altTextValue.trim() || null
|
||
})
|
||
})
|
||
|
||
if (response.ok) {
|
||
const updatedData = await response.json()
|
||
value = { ...value, altText: updatedData.altText, updatedAt: updatedData.updatedAt }
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update alt text:', error)
|
||
}
|
||
}
|
||
|
||
async function handleDescriptionChange() {
|
||
if (!value) return
|
||
|
||
try {
|
||
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
description: descriptionValue.trim() || null
|
||
})
|
||
})
|
||
|
||
if (response.ok) {
|
||
const updatedData = await response.json()
|
||
value = { ...value, description: updatedData.description, updatedAt: updatedData.updatedAt }
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update description:', error)
|
||
}
|
||
}
|
||
|
||
// Browse library handler
|
||
function handleBrowseLibrary() {
|
||
isMediaLibraryOpen = true
|
||
}
|
||
|
||
function handleMediaSelect(selectedMedia: Media | Media[]) {
|
||
// Since this is single mode, selectedMedia will be a single Media object
|
||
const media = selectedMedia as Media
|
||
value = media
|
||
altTextValue = media.altText || ''
|
||
descriptionValue = media.description || ''
|
||
onUpload(media)
|
||
}
|
||
|
||
function handleMediaLibraryClose() {
|
||
isMediaLibraryOpen = false
|
||
}
|
||
</script>
|
||
|
||
<div class="image-uploader" class:compact>
|
||
<!-- Label -->
|
||
<label class="uploader-label">
|
||
{label}
|
||
{#if required}
|
||
<span class="required">*</span>
|
||
{/if}
|
||
</label>
|
||
|
||
{#if helpText}
|
||
<p class="help-text">{helpText}</p>
|
||
{/if}
|
||
|
||
<!-- Upload Area or Preview -->
|
||
<div class="upload-container">
|
||
{#if hasValue && !isUploading}
|
||
{#if compact}
|
||
<!-- Compact Layout: Image and metadata side-by-side -->
|
||
<div class="compact-preview">
|
||
<div class="compact-image">
|
||
<SmartImage
|
||
media={value}
|
||
alt={value?.altText || value?.filename || 'Uploaded image'}
|
||
containerWidth={100}
|
||
loading="eager"
|
||
{aspectRatio}
|
||
class="preview-image"
|
||
/>
|
||
|
||
<!-- Overlay with actions -->
|
||
<div class="preview-overlay">
|
||
<div class="preview-actions">
|
||
<Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}>
|
||
<RefreshIcon slot="icon" width="12" height="12" />
|
||
</Button>
|
||
|
||
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
|
||
<svg
|
||
slot="icon"
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<polyline
|
||
points="3,6 5,6 21,6"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
<path
|
||
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
</svg>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="compact-info">
|
||
<!-- Alt Text Input in compact mode -->
|
||
{#if allowAltText}
|
||
<div class="compact-metadata">
|
||
<Input
|
||
type="text"
|
||
label="Alt Text"
|
||
bind:value={altTextValue}
|
||
placeholder="Describe this image for screen readers"
|
||
buttonSize="small"
|
||
onblur={handleAltTextChange}
|
||
/>
|
||
|
||
<Input
|
||
type="textarea"
|
||
label="Description (Optional)"
|
||
bind:value={descriptionValue}
|
||
placeholder="Additional description or caption"
|
||
rows={2}
|
||
buttonSize="small"
|
||
onblur={handleDescriptionChange}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<!-- Standard Layout: Image preview -->
|
||
<div class="image-preview" style={aspectRatioStyle}>
|
||
<SmartImage
|
||
media={value}
|
||
alt={value?.altText || value?.filename || 'Uploaded image'}
|
||
containerWidth={800}
|
||
loading="eager"
|
||
{aspectRatio}
|
||
class="preview-image"
|
||
/>
|
||
|
||
<!-- Overlay with actions -->
|
||
<div class="preview-overlay">
|
||
<div class="preview-actions">
|
||
<Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}>
|
||
<RefreshIcon slot="icon" width="16" height="16" />
|
||
Replace
|
||
</Button>
|
||
|
||
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
|
||
<svg
|
||
slot="icon"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<polyline
|
||
points="3,6 5,6 21,6"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
<path
|
||
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
</svg>
|
||
Remove
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File Info -->
|
||
<div class="file-info">
|
||
<p class="filename">{value?.originalName || value?.filename}</p>
|
||
<p class="file-meta">
|
||
{Math.round((value?.size || 0) / 1024)} KB
|
||
{#if value?.width && value?.height}
|
||
• {value.width}×{value.height}
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<!-- Upload Drop Zone -->
|
||
<div
|
||
class="drop-zone"
|
||
class:drag-over={isDragOver}
|
||
class:uploading={isUploading}
|
||
class:has-error={!!uploadError}
|
||
style={aspectRatioStyle}
|
||
ondragover={handleDragOver}
|
||
ondragleave={handleDragLeave}
|
||
ondrop={handleDrop}
|
||
onclick={handleBrowseClick}
|
||
>
|
||
{#if isUploading}
|
||
<!-- Upload Progress -->
|
||
<div class="upload-progress">
|
||
<svg class="upload-spinner" width="24" height="24" viewBox="0 0 24 24">
|
||
<circle
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
fill="none"
|
||
stroke-dasharray="60"
|
||
stroke-dashoffset="60"
|
||
stroke-linecap="round"
|
||
>
|
||
<animateTransform
|
||
attributeName="transform"
|
||
type="rotate"
|
||
from="0 12 12"
|
||
to="360 12 12"
|
||
dur="1s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</circle>
|
||
</svg>
|
||
<p class="upload-text">Uploading... {Math.round(uploadProgress)}%</p>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" style="width: {uploadProgress}%"></div>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<!-- Upload Prompt -->
|
||
<div class="upload-prompt">
|
||
<svg
|
||
class="upload-icon"
|
||
width="48"
|
||
height="48"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
<polyline
|
||
points="14,2 14,8 20,8"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
<line
|
||
x1="16"
|
||
y1="13"
|
||
x2="8"
|
||
y2="13"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
/>
|
||
<line
|
||
x1="16"
|
||
y1="17"
|
||
x2="8"
|
||
y2="17"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
/>
|
||
<polyline
|
||
points="10,9 9,9 8,9"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
</svg>
|
||
<p class="upload-main-text">{placeholder}</p>
|
||
<p class="upload-sub-text">
|
||
Supports JPG, PNG, GIF up to {maxFileSize}MB
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Action Buttons -->
|
||
{#if !hasValue && !isUploading}
|
||
<div class="action-buttons">
|
||
<Button variant="primary" onclick={handleBrowseClick}>Choose File</Button>
|
||
|
||
{#if showBrowseLibrary}
|
||
<Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Alt Text Input (only in standard mode, compact mode has it inline) -->
|
||
{#if allowAltText && hasValue && !compact}
|
||
<div class="metadata-section">
|
||
<Input
|
||
type="text"
|
||
label="Alt Text"
|
||
bind:value={altTextValue}
|
||
placeholder="Describe this image for screen readers"
|
||
helpText="Help make your content accessible. Describe what's in the image."
|
||
onblur={handleAltTextChange}
|
||
/>
|
||
|
||
<Input
|
||
type="textarea"
|
||
label="Description (Optional)"
|
||
bind:value={descriptionValue}
|
||
placeholder="Additional description or caption"
|
||
rows={2}
|
||
onblur={handleDescriptionChange}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Error Message -->
|
||
{#if error || uploadError}
|
||
<p class="error-message">{error || uploadError}</p>
|
||
{/if}
|
||
|
||
<!-- Hidden File Input -->
|
||
<input
|
||
bind:this={fileInputElement}
|
||
type="file"
|
||
accept="image/*"
|
||
style="display: none;"
|
||
onchange={handleFileInputChange}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Media Library Modal -->
|
||
<MediaLibraryModal
|
||
bind:isOpen={isMediaLibraryOpen}
|
||
mode="single"
|
||
fileType="image"
|
||
title="Select Image"
|
||
confirmText="Select Image"
|
||
onSelect={handleMediaSelect}
|
||
onClose={handleMediaLibraryClose}
|
||
/>
|
||
|
||
<style lang="scss">
|
||
.image-uploader {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: $unit-2x;
|
||
|
||
&.compact {
|
||
gap: $unit;
|
||
}
|
||
}
|
||
|
||
.uploader-label {
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
color: $grey-20;
|
||
|
||
.required {
|
||
color: $red-60;
|
||
margin-left: $unit-half;
|
||
}
|
||
}
|
||
|
||
.help-text {
|
||
margin: 0;
|
||
font-size: 0.8rem;
|
||
color: $grey-40;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.upload-container {
|
||
position: relative;
|
||
}
|
||
|
||
// Drop Zone Styles
|
||
.drop-zone {
|
||
border: 2px dashed $grey-80;
|
||
border-radius: $card-corner-radius;
|
||
background-color: $grey-97;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
min-height: 200px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
overflow: hidden;
|
||
|
||
&:hover {
|
||
border-color: $blue-60;
|
||
background-color: rgba($blue-60, 0.02);
|
||
}
|
||
|
||
&.drag-over {
|
||
border-color: $blue-60;
|
||
background-color: rgba($blue-60, 0.05);
|
||
border-style: solid;
|
||
}
|
||
|
||
&.uploading {
|
||
cursor: default;
|
||
border-color: $blue-60;
|
||
}
|
||
|
||
&.has-error {
|
||
border-color: $red-60;
|
||
background-color: rgba($red-60, 0.02);
|
||
}
|
||
}
|
||
|
||
.upload-prompt {
|
||
text-align: center;
|
||
padding: $unit-4x;
|
||
|
||
.upload-icon {
|
||
color: $grey-50;
|
||
margin-bottom: $unit-2x;
|
||
}
|
||
|
||
.upload-main-text {
|
||
margin: 0 0 $unit 0;
|
||
font-size: 0.875rem;
|
||
color: $grey-30;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.upload-sub-text {
|
||
margin: 0;
|
||
font-size: 0.75rem;
|
||
color: $grey-50;
|
||
}
|
||
}
|
||
|
||
.upload-progress {
|
||
text-align: center;
|
||
padding: $unit-4x;
|
||
|
||
.upload-spinner {
|
||
color: $blue-60;
|
||
margin-bottom: $unit-2x;
|
||
}
|
||
|
||
.upload-text {
|
||
margin: 0 0 $unit-2x 0;
|
||
font-size: 0.875rem;
|
||
color: $grey-30;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.progress-bar {
|
||
width: 200px;
|
||
height: 4px;
|
||
background-color: $grey-90;
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
margin: 0 auto;
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background-color: $blue-60;
|
||
transition: width 0.3s ease;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Image Preview Styles
|
||
.image-preview {
|
||
position: relative;
|
||
border-radius: $card-corner-radius;
|
||
overflow: hidden;
|
||
background-color: $grey-95;
|
||
min-height: 200px;
|
||
|
||
:global(.preview-image) {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.preview-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
&:hover .preview-overlay {
|
||
opacity: 1;
|
||
}
|
||
|
||
.preview-actions {
|
||
display: flex;
|
||
gap: $unit;
|
||
}
|
||
}
|
||
|
||
.file-info {
|
||
margin-top: $unit-2x;
|
||
|
||
.filename {
|
||
margin: 0 0 $unit-half 0;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
color: $grey-10;
|
||
}
|
||
|
||
.file-meta {
|
||
margin: 0;
|
||
font-size: 0.75rem;
|
||
color: $grey-40;
|
||
}
|
||
}
|
||
|
||
.action-buttons {
|
||
display: flex;
|
||
gap: $unit-2x;
|
||
align-items: center;
|
||
}
|
||
|
||
.metadata-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: $unit-2x;
|
||
padding: $unit-3x;
|
||
background-color: $grey-97;
|
||
border-radius: $card-corner-radius;
|
||
border: 1px solid $grey-90;
|
||
}
|
||
|
||
.error-message {
|
||
margin: 0;
|
||
font-size: 0.75rem;
|
||
color: $red-60;
|
||
padding: $unit;
|
||
background-color: rgba($red-60, 0.05);
|
||
border-radius: $card-corner-radius;
|
||
border: 1px solid rgba($red-60, 0.2);
|
||
}
|
||
|
||
// Compact layout styles
|
||
.compact-preview {
|
||
display: flex;
|
||
gap: $unit-3x;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.compact-image {
|
||
position: relative;
|
||
width: 100px;
|
||
height: 100px;
|
||
flex-shrink: 0;
|
||
border-radius: $card-corner-radius;
|
||
overflow: hidden;
|
||
background-color: $grey-95;
|
||
border: 1px solid $grey-90;
|
||
|
||
:global(.preview-image) {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
display: block;
|
||
padding: $unit-3x;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.preview-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
&:hover .preview-overlay {
|
||
opacity: 1;
|
||
}
|
||
|
||
.preview-actions {
|
||
display: flex;
|
||
gap: $unit-half;
|
||
}
|
||
}
|
||
|
||
.compact-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.compact-metadata {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: $unit-2x;
|
||
}
|
||
}
|
||
|
||
// Responsive adjustments
|
||
@media (max-width: 640px) {
|
||
.upload-prompt {
|
||
padding: $unit-3x;
|
||
|
||
.upload-main-text {
|
||
font-size: 0.8rem;
|
||
}
|
||
}
|
||
|
||
.action-buttons {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.preview-actions {
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
</style>
|