feat: implement unified content insertion pane for Edra editor
- Create ContentInsertionPane component with segmented controls - Update image placeholder to single-line format with icon + text - Update gallery placeholder to use unified pane - Support upload, embed link, and gallery selection for each type - Add location search placeholder for future geocoding integration
This commit is contained in:
parent
737a34b950
commit
12c30c1501
3 changed files with 710 additions and 448 deletions
|
|
@ -0,0 +1,608 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Editor } from '@tiptap/core'
|
||||||
|
import type { Media } from '@prisma/client'
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import Image from 'lucide-svelte/icons/image'
|
||||||
|
import Video from 'lucide-svelte/icons/video'
|
||||||
|
import AudioLines from 'lucide-svelte/icons/audio-lines'
|
||||||
|
import Grid3x3 from 'lucide-svelte/icons/grid-3x3'
|
||||||
|
import MapPin from 'lucide-svelte/icons/map-pin'
|
||||||
|
import Upload from 'lucide-svelte/icons/upload'
|
||||||
|
import Link from 'lucide-svelte/icons/link'
|
||||||
|
import Images from 'lucide-svelte/icons/images'
|
||||||
|
import Search from 'lucide-svelte/icons/search'
|
||||||
|
import X from 'lucide-svelte/icons/x'
|
||||||
|
import { mediaSelectionStore } from '$lib/stores/media-selection'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: Editor
|
||||||
|
position: { x: number; y: number }
|
||||||
|
onClose: () => void
|
||||||
|
deleteNode?: () => void
|
||||||
|
albumId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let { editor, position, onClose, deleteNode, albumId }: Props = $props()
|
||||||
|
|
||||||
|
type ContentType = 'image' | 'video' | 'audio' | 'gallery' | 'location'
|
||||||
|
type ActionType = 'upload' | 'embed' | 'gallery' | 'search'
|
||||||
|
|
||||||
|
let selectedType = $state<ContentType>('image')
|
||||||
|
let embedUrl = $state('')
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let isUploading = $state(false)
|
||||||
|
let fileInput: HTMLInputElement
|
||||||
|
|
||||||
|
const contentTypes = [
|
||||||
|
{ type: 'image' as ContentType, icon: Image, label: 'Image' },
|
||||||
|
{ type: 'video' as ContentType, icon: Video, label: 'Video' },
|
||||||
|
{ type: 'audio' as ContentType, icon: AudioLines, label: 'Audio' },
|
||||||
|
{ type: 'gallery' as ContentType, icon: Grid3x3, label: 'Gallery' },
|
||||||
|
{ type: 'location' as ContentType, icon: MapPin, label: 'Location' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const availableActions = $derived(() => {
|
||||||
|
switch (selectedType) {
|
||||||
|
case 'image':
|
||||||
|
case 'video':
|
||||||
|
case 'audio':
|
||||||
|
return [
|
||||||
|
{ type: 'upload' as ActionType, icon: Upload, label: 'Upload' },
|
||||||
|
{ type: 'embed' as ActionType, icon: Link, label: 'Embed link' },
|
||||||
|
{ type: 'gallery' as ActionType, icon: Images, label: 'Choose from gallery' }
|
||||||
|
]
|
||||||
|
case 'gallery':
|
||||||
|
return [{ type: 'gallery' as ActionType, icon: Images, label: 'Choose images from gallery' }]
|
||||||
|
case 'location':
|
||||||
|
return [
|
||||||
|
{ type: 'search' as ActionType, icon: Search, label: 'Search' },
|
||||||
|
{ type: 'embed' as ActionType, icon: Link, label: 'Embed link' }
|
||||||
|
]
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleTypeSelect(type: ContentType) {
|
||||||
|
selectedType = type
|
||||||
|
embedUrl = ''
|
||||||
|
searchQuery = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpload() {
|
||||||
|
if (!fileInput) return
|
||||||
|
|
||||||
|
// Set accept attribute based on type
|
||||||
|
switch (selectedType) {
|
||||||
|
case 'image':
|
||||||
|
fileInput.accept = 'image/*'
|
||||||
|
break
|
||||||
|
case 'video':
|
||||||
|
fileInput.accept = 'video/*'
|
||||||
|
break
|
||||||
|
case 'audio':
|
||||||
|
fileInput.accept = 'audio/*'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const files = input.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
isUploading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = files[0]
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('type', selectedType)
|
||||||
|
|
||||||
|
if (albumId) {
|
||||||
|
formData.append('albumId', albumId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (auth) {
|
||||||
|
headers.Authorization = `Basic ${auth}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/media/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const media = await response.json()
|
||||||
|
insertContent(media)
|
||||||
|
} else {
|
||||||
|
console.error('Failed to upload file:', response.status)
|
||||||
|
alert('Failed to upload file. Please try again.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file:', error)
|
||||||
|
alert('Failed to upload file. Please try again.')
|
||||||
|
} finally {
|
||||||
|
isUploading = false
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmbed() {
|
||||||
|
if (!embedUrl.trim()) return
|
||||||
|
|
||||||
|
switch (selectedType) {
|
||||||
|
case 'image':
|
||||||
|
editor.chain().focus().setImage({ src: embedUrl }).run()
|
||||||
|
break
|
||||||
|
case 'video':
|
||||||
|
editor.chain().focus().setVideo(embedUrl).run()
|
||||||
|
break
|
||||||
|
case 'audio':
|
||||||
|
editor.chain().focus().setAudio(embedUrl).run()
|
||||||
|
break
|
||||||
|
case 'location':
|
||||||
|
// For location, try to extract coordinates from Google Maps URL
|
||||||
|
const coords = extractCoordinatesFromUrl(embedUrl)
|
||||||
|
if (coords) {
|
||||||
|
editor.chain().focus().insertContent({
|
||||||
|
type: 'geolocation',
|
||||||
|
attrs: {
|
||||||
|
latitude: coords.lat,
|
||||||
|
longitude: coords.lng,
|
||||||
|
title: 'Location',
|
||||||
|
description: ''
|
||||||
|
}
|
||||||
|
}).run()
|
||||||
|
} else {
|
||||||
|
alert('Please enter a valid Google Maps URL')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteNode?.()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGallerySelect() {
|
||||||
|
const fileType = selectedType === 'gallery' ? 'image' : selectedType
|
||||||
|
const mode = selectedType === 'gallery' ? 'multiple' : 'single'
|
||||||
|
|
||||||
|
mediaSelectionStore.open({
|
||||||
|
mode,
|
||||||
|
fileType: fileType as 'image' | 'video' | 'audio',
|
||||||
|
albumId,
|
||||||
|
onSelect: (media: Media | Media[]) => {
|
||||||
|
if (selectedType === 'gallery') {
|
||||||
|
insertGallery(media as Media[])
|
||||||
|
} else {
|
||||||
|
insertContent(media as Media)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
mediaSelectionStore.close()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertContent(media: Media) {
|
||||||
|
switch (selectedType) {
|
||||||
|
case 'image':
|
||||||
|
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
||||||
|
editor.chain().focus().insertContent({
|
||||||
|
type: 'image',
|
||||||
|
attrs: {
|
||||||
|
src: media.url,
|
||||||
|
alt: media.altText || '',
|
||||||
|
title: media.description || '',
|
||||||
|
width: displayWidth,
|
||||||
|
height: media.height,
|
||||||
|
align: 'center',
|
||||||
|
mediaId: media.id?.toString()
|
||||||
|
}
|
||||||
|
}).run()
|
||||||
|
break
|
||||||
|
case 'video':
|
||||||
|
editor.chain().focus().setVideo(media.url).run()
|
||||||
|
break
|
||||||
|
case 'audio':
|
||||||
|
editor.chain().focus().setAudio(media.url).run()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteNode?.()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertGallery(mediaArray: Media[]) {
|
||||||
|
if (mediaArray.length > 0) {
|
||||||
|
const galleryImages = mediaArray.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
url: m.url,
|
||||||
|
alt: m.altText || '',
|
||||||
|
title: m.description || ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
editor.chain().focus().setGallery({ images: galleryImages }).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteNode?.()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCoordinatesFromUrl(url: string): { lat: number; lng: number } | null {
|
||||||
|
// Extract from Google Maps URL patterns
|
||||||
|
const patterns = [
|
||||||
|
/@(-?\d+\.\d+),(-?\d+\.\d+)/, // @lat,lng format
|
||||||
|
/ll=(-?\d+\.\d+),(-?\d+\.\d+)/, // ll=lat,lng format
|
||||||
|
/q=(-?\d+\.\d+),(-?\d+\.\d+)/ // q=lat,lng format
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = url.match(pattern)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
lat: parseFloat(match[1]),
|
||||||
|
lng: parseFloat(match[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocationSearch() {
|
||||||
|
// This would integrate with a geocoding API
|
||||||
|
// For now, just show a message
|
||||||
|
alert('Location search coming soon! For now, paste a Google Maps link.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
} else if (e.key === 'Enter' && embedUrl.trim()) {
|
||||||
|
handleEmbed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (!target.closest('.content-insertion-pane')) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="content-insertion-pane"
|
||||||
|
style="top: {position.y}px; left: {position.x}px;"
|
||||||
|
>
|
||||||
|
<div class="pane-header">
|
||||||
|
<div class="content-types">
|
||||||
|
{#each contentTypes as contentType}
|
||||||
|
<button
|
||||||
|
class="content-type-btn"
|
||||||
|
class:active={selectedType === contentType.type}
|
||||||
|
onclick={() => handleTypeSelect(contentType.type)}
|
||||||
|
>
|
||||||
|
<svelte:component this={contentType.icon} size={16} />
|
||||||
|
<span>{contentType.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pane-content">
|
||||||
|
{#if selectedType === 'location' && availableActions()[0]?.type === 'search'}
|
||||||
|
<div class="search-section">
|
||||||
|
<input
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Search for a location..."
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
<button class="search-btn" onclick={handleLocationSearch}>
|
||||||
|
<Search size={16} />
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="divider">or</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if availableActions().some(a => a.type === 'embed')}
|
||||||
|
<div class="embed-section">
|
||||||
|
<input
|
||||||
|
bind:value={embedUrl}
|
||||||
|
placeholder={selectedType === 'location' ? 'Paste Google Maps link...' : `Paste ${selectedType} URL...`}
|
||||||
|
class="embed-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="embed-btn"
|
||||||
|
onclick={handleEmbed}
|
||||||
|
disabled={!embedUrl.trim()}
|
||||||
|
>
|
||||||
|
Embed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if availableActions().length > 1 && availableActions().some(a => a.type !== 'embed')}
|
||||||
|
<div class="divider">or</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
{#each availableActions().filter(a => a.type !== 'embed') as action}
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
onclick={() => {
|
||||||
|
if (action.type === 'upload') handleUpload()
|
||||||
|
else if (action.type === 'gallery') handleGallerySelect()
|
||||||
|
else if (action.type === 'search') handleLocationSearch()
|
||||||
|
}}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<svelte:component this={action.icon} size={20} />
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isUploading}
|
||||||
|
<div class="uploading-overlay">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Uploading...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
onchange={handleFileUpload}
|
||||||
|
style="display: none;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '$styles/variables';
|
||||||
|
|
||||||
|
.content-insertion-pane {
|
||||||
|
position: fixed;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-85;
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
width: 400px;
|
||||||
|
z-index: $z-index-modal;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $unit-2x;
|
||||||
|
border-bottom: 1px solid $gray-90;
|
||||||
|
background: $gray-98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-types {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-type-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
padding: $unit-half $unit-2x;
|
||||||
|
border: none;
|
||||||
|
border-radius: $corner-radius-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-40;
|
||||||
|
font-size: $font-size-extra-small;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-95;
|
||||||
|
color: $gray-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $gray-90;
|
||||||
|
color: $gray-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: $unit-3x;
|
||||||
|
height: $unit-3x;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: $corner-radius-xs;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-50;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-90;
|
||||||
|
color: $gray-20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-content {
|
||||||
|
padding: $unit-3x;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section,
|
||||||
|
.embed-section {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit;
|
||||||
|
margin-bottom: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.embed-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
border: 1px solid $gray-85;
|
||||||
|
border-radius: $corner-radius-sm;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
background: $white;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn,
|
||||||
|
.embed-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
border: none;
|
||||||
|
border-radius: $corner-radius-sm;
|
||||||
|
background: $primary-color;
|
||||||
|
color: $white;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: darken($primary-color, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
text-align: center;
|
||||||
|
color: $gray-60;
|
||||||
|
font-size: $font-size-extra-small;
|
||||||
|
margin: $unit-2x 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: calc(50% - $unit-3x);
|
||||||
|
height: 1px;
|
||||||
|
background: $gray-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
width: 100%;
|
||||||
|
padding: $unit-2x;
|
||||||
|
border: 1px solid $gray-85;
|
||||||
|
border-radius: $corner-radius-sm;
|
||||||
|
background: $white;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $gray-20;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-95;
|
||||||
|
border-color: $gray-70;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba($white, 0.9);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit;
|
||||||
|
color: $gray-50;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: $unit-3x;
|
||||||
|
height: $unit-3x;
|
||||||
|
border: 2px solid $gray-90;
|
||||||
|
border-top: 2px solid $primary-color;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,267 +1,91 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeViewProps } from '@tiptap/core'
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
import type { Media } from '@prisma/client'
|
|
||||||
import Image from 'lucide-svelte/icons/image'
|
import Image from 'lucide-svelte/icons/image'
|
||||||
import Upload from 'lucide-svelte/icons/upload'
|
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
import { getContext, onMount } from 'svelte'
|
import { getContext } from 'svelte'
|
||||||
import { mediaSelectionStore } from '$lib/stores/media-selection'
|
import ContentInsertionPane from './ContentInsertionPane.svelte'
|
||||||
|
|
||||||
const { editor, deleteNode }: NodeViewProps = $props()
|
const { editor, deleteNode, getPos }: NodeViewProps = $props()
|
||||||
|
|
||||||
// Get album context if available
|
// Get album context if available
|
||||||
const editorContext = getContext<any>('editorContext') || {}
|
const editorContext = getContext<any>('editorContext') || {}
|
||||||
const albumId = $derived(editorContext.albumId)
|
const albumId = $derived(editorContext.albumId)
|
||||||
|
|
||||||
let fileInput: HTMLInputElement
|
let showPane = $state(false)
|
||||||
let isUploading = $state(false)
|
let panePosition = $state({ x: 0, y: 0 })
|
||||||
let autoOpenModal = $state(false)
|
|
||||||
|
|
||||||
// If configured to auto-open modal, do it on mount
|
function handleClick(e: MouseEvent) {
|
||||||
onMount(() => {
|
|
||||||
// Check if we should auto-open from editor storage
|
|
||||||
if (editor.storage.imageModal?.placeholderPos !== undefined) {
|
|
||||||
autoOpenModal = true
|
|
||||||
// Modal is already open from the composer level
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleBrowseLibrary(e: MouseEvent) {
|
|
||||||
if (!editor.isEditable) return
|
if (!editor.isEditable) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
// Open modal through the store
|
// Get position for pane
|
||||||
mediaSelectionStore.open({
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
mode: 'single',
|
panePosition = {
|
||||||
fileType: 'image',
|
x: rect.left,
|
||||||
albumId,
|
y: rect.bottom + 8
|
||||||
onSelect: handleMediaSelect,
|
|
||||||
onClose: handleMediaLibraryClose
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDirectUpload(e: MouseEvent) {
|
|
||||||
if (!editor.isEditable) return
|
|
||||||
e.preventDefault()
|
|
||||||
fileInput.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMediaSelect(media: Media | Media[]) {
|
|
||||||
const selectedMedia = Array.isArray(media) ? media[0] : media
|
|
||||||
if (selectedMedia) {
|
|
||||||
// Set a reasonable default width (max 600px)
|
|
||||||
const displayWidth =
|
|
||||||
selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
|
|
||||||
|
|
||||||
const imageAttrs = {
|
|
||||||
src: selectedMedia.url,
|
|
||||||
alt: selectedMedia.altText || '',
|
|
||||||
title: selectedMedia.description || '',
|
|
||||||
width: displayWidth,
|
|
||||||
height: selectedMedia.height,
|
|
||||||
align: 'center',
|
|
||||||
mediaId: selectedMedia.id?.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.insertContent({
|
|
||||||
type: 'image',
|
|
||||||
attrs: imageAttrs
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the store
|
|
||||||
mediaSelectionStore.close()
|
|
||||||
|
|
||||||
// Delete the placeholder node
|
|
||||||
deleteNode()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMediaLibraryClose() {
|
|
||||||
// Close the store
|
|
||||||
mediaSelectionStore.close()
|
|
||||||
|
|
||||||
// Delete placeholder if user cancelled
|
|
||||||
deleteNode()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFileUpload(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const files = input.files
|
|
||||||
if (!files || files.length === 0) return
|
|
||||||
|
|
||||||
const file = files[0]
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
alert('Please select an image file.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file size (2MB max)
|
|
||||||
const filesize = file.size / 1024 / 1024
|
|
||||||
if (filesize > 2) {
|
|
||||||
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isUploading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
formData.append('type', 'image')
|
|
||||||
|
|
||||||
// If we have an albumId, add it to the upload
|
|
||||||
if (albumId) {
|
|
||||||
formData.append('albumId', albumId.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add auth header if needed
|
|
||||||
const auth = localStorage.getItem('admin_auth')
|
|
||||||
const headers: Record<string, string> = {}
|
|
||||||
if (auth) {
|
|
||||||
headers.Authorization = `Basic ${auth}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/media/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const media = await response.json()
|
|
||||||
|
|
||||||
// Set a reasonable default width (max 600px)
|
|
||||||
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
|
||||||
|
|
||||||
const imageAttrs = {
|
|
||||||
src: media.url,
|
|
||||||
alt: media.altText || '',
|
|
||||||
title: media.description || '',
|
|
||||||
width: displayWidth,
|
|
||||||
height: media.height,
|
|
||||||
align: 'center',
|
|
||||||
mediaId: media.id?.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.insertContent({
|
|
||||||
type: 'image',
|
|
||||||
attrs: imageAttrs
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
} else {
|
|
||||||
console.error('Failed to upload image:', response.status)
|
|
||||||
alert('Failed to upload image. Please try again.')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uploading image:', error)
|
|
||||||
alert('Failed to upload image. Please try again.')
|
|
||||||
} finally {
|
|
||||||
isUploading = false
|
|
||||||
// Clear the input
|
|
||||||
input.value = ''
|
|
||||||
}
|
}
|
||||||
|
showPane = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleBrowseLibrary(e as any)
|
handleClick(e as any)
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
deleteNode()
|
if (showPane) {
|
||||||
|
showPane = false
|
||||||
|
} else {
|
||||||
|
deleteNode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
||||||
<div class="edra-media-placeholder-container">
|
<button
|
||||||
{#if isUploading}
|
class="edra-media-placeholder-content"
|
||||||
<div class="edra-media-placeholder-uploading">
|
onclick={handleClick}
|
||||||
<div class="spinner"></div>
|
onkeydown={handleKeyDown}
|
||||||
<span>Uploading...</span>
|
tabindex="0"
|
||||||
</div>
|
aria-label="Insert an image"
|
||||||
{:else if !autoOpenModal}
|
>
|
||||||
<button
|
<Image class="edra-media-placeholder-icon" />
|
||||||
class="edra-media-placeholder-option"
|
<span class="edra-media-placeholder-text">Insert an image</span>
|
||||||
onclick={handleDirectUpload}
|
</button>
|
||||||
onkeydown={handleKeyDown}
|
|
||||||
tabindex="0"
|
{#if showPane}
|
||||||
aria-label="Upload Image"
|
<ContentInsertionPane
|
||||||
title="Upload from device"
|
{editor}
|
||||||
>
|
position={panePosition}
|
||||||
<Upload class="edra-media-placeholder-icon" />
|
onClose={() => showPane = false}
|
||||||
<span class="edra-media-placeholder-text">Upload Image</span>
|
{deleteNode}
|
||||||
</button>
|
{albumId}
|
||||||
|
/>
|
||||||
<button
|
{/if}
|
||||||
class="edra-media-placeholder-option"
|
|
||||||
onclick={handleBrowseLibrary}
|
|
||||||
onkeydown={handleKeyDown}
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="Browse Media Library"
|
|
||||||
title="Choose from library"
|
|
||||||
>
|
|
||||||
<Image class="edra-media-placeholder-icon" />
|
|
||||||
<span class="edra-media-placeholder-text">Browse Library</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden file input -->
|
|
||||||
<input
|
|
||||||
bind:this={fileInput}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onchange={handleFileUpload}
|
|
||||||
style="display: none;"
|
|
||||||
/>
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '$styles/variables';
|
@import '$styles/variables';
|
||||||
|
|
||||||
.edra-media-placeholder-container {
|
.edra-media-placeholder-content {
|
||||||
display: flex;
|
width: 100%;
|
||||||
gap: $unit-2x;
|
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
|
background-color: $gray-95;
|
||||||
border: 2px dashed $gray-85;
|
border: 2px dashed $gray-85;
|
||||||
border-radius: $corner-radius;
|
border-radius: $corner-radius;
|
||||||
background: $gray-95;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 80px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: $gray-70;
|
|
||||||
background: $gray-90;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.edra-media-placeholder-option {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $unit;
|
justify-content: center;
|
||||||
padding: $unit-2x $unit-3x;
|
gap: $unit-2x;
|
||||||
border: 1px solid $gray-85;
|
|
||||||
border-radius: $corner-radius-sm;
|
|
||||||
background: $white;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
min-width: 140px;
|
color: $gray-50;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
background-color: $gray-90;
|
||||||
border-color: $gray-70;
|
border-color: $gray-70;
|
||||||
background: $gray-95;
|
color: $gray-40;
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
|
|
@ -271,41 +95,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.edra-media-placeholder-uploading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit;
|
|
||||||
padding: $unit-3x;
|
|
||||||
color: $gray-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: $unit-2x;
|
|
||||||
height: $unit-2x;
|
|
||||||
border: 2px solid $gray-90;
|
|
||||||
border-top: 2px solid $primary-color;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.edra-media-placeholder-icon) {
|
:global(.edra-media-placeholder-icon) {
|
||||||
width: $unit-3x + $unit-half;
|
width: $unit-3x;
|
||||||
height: $unit-3x + $unit-half;
|
height: $unit-3x;
|
||||||
color: $gray-50;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edra-media-placeholder-text {
|
.edra-media-placeholder-text {
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-small;
|
||||||
color: $gray-50;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,205 +1,91 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeViewProps } from '@tiptap/core'
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
import type { Media } from '@prisma/client'
|
import Grid3x3 from 'lucide-svelte/icons/grid-3x3'
|
||||||
import Grid from 'lucide-svelte/icons/grid-3x3'
|
|
||||||
import Upload from 'lucide-svelte/icons/upload'
|
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
import UnifiedMediaModal from '../../../admin/UnifiedMediaModal.svelte'
|
import { getContext } from 'svelte'
|
||||||
|
import ContentInsertionPane from './ContentInsertionPane.svelte'
|
||||||
|
|
||||||
const { editor, deleteNode }: NodeViewProps = $props()
|
const { editor, deleteNode }: NodeViewProps = $props()
|
||||||
|
|
||||||
let isMediaLibraryOpen = $state(false)
|
// Get album context if available
|
||||||
let fileInput: HTMLInputElement
|
const editorContext = getContext<any>('editorContext') || {}
|
||||||
let isUploading = $state(false)
|
const albumId = $derived(editorContext.albumId)
|
||||||
|
|
||||||
function handleBrowseLibrary(e: MouseEvent) {
|
let showPane = $state(false)
|
||||||
|
let panePosition = $state({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
if (!editor.isEditable) return
|
if (!editor.isEditable) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
isMediaLibraryOpen = true
|
|
||||||
}
|
// Get position for pane
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
function handleDirectUpload(e: MouseEvent) {
|
panePosition = {
|
||||||
if (!editor.isEditable) return
|
x: rect.left,
|
||||||
e.preventDefault()
|
y: rect.bottom + 8
|
||||||
fileInput.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMediaSelect(media: Media | Media[]) {
|
|
||||||
const mediaArray = Array.isArray(media) ? media : [media]
|
|
||||||
if (mediaArray.length > 0) {
|
|
||||||
const galleryImages = mediaArray.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
url: m.url,
|
|
||||||
alt: m.altText || '',
|
|
||||||
title: m.description || ''
|
|
||||||
}))
|
|
||||||
|
|
||||||
editor.chain().focus().setGallery({ images: galleryImages }).run()
|
|
||||||
}
|
|
||||||
isMediaLibraryOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMediaLibraryClose() {
|
|
||||||
isMediaLibraryOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFileUpload(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const files = input.files
|
|
||||||
if (!files || files.length === 0) return
|
|
||||||
|
|
||||||
isUploading = true
|
|
||||||
const uploadedImages = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file.type.startsWith('image/')) continue
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
formData.append('type', 'image')
|
|
||||||
|
|
||||||
// Add auth header if needed
|
|
||||||
const auth = localStorage.getItem('admin_auth')
|
|
||||||
const headers: Record<string, string> = {}
|
|
||||||
if (auth) {
|
|
||||||
headers.Authorization = `Basic ${auth}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/media/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const media = await response.json()
|
|
||||||
uploadedImages.push({
|
|
||||||
id: media.id,
|
|
||||||
url: media.url,
|
|
||||||
alt: media.altText || '',
|
|
||||||
title: media.description || ''
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.error('Failed to upload image:', response.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uploadedImages.length > 0) {
|
|
||||||
editor.chain().focus().setGallery({ images: uploadedImages }).run()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uploading images:', error)
|
|
||||||
alert('Failed to upload some images. Please try again.')
|
|
||||||
} finally {
|
|
||||||
isUploading = false
|
|
||||||
// Clear the input
|
|
||||||
input.value = ''
|
|
||||||
}
|
}
|
||||||
|
showPane = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleBrowseLibrary(e as any)
|
handleClick(e as any)
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
deleteNode()
|
if (showPane) {
|
||||||
|
showPane = false
|
||||||
|
} else {
|
||||||
|
deleteNode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NodeViewWrapper class="edra-gallery-placeholder-wrapper" contenteditable="false">
|
<NodeViewWrapper class="edra-gallery-placeholder-wrapper" contenteditable="false">
|
||||||
<div class="edra-gallery-placeholder-container">
|
<button
|
||||||
{#if isUploading}
|
class="edra-gallery-placeholder-content"
|
||||||
<div class="edra-gallery-placeholder-uploading">
|
onclick={handleClick}
|
||||||
<div class="spinner"></div>
|
onkeydown={handleKeyDown}
|
||||||
<span>Uploading images...</span>
|
tabindex="0"
|
||||||
</div>
|
aria-label="Insert a gallery"
|
||||||
{:else}
|
>
|
||||||
<button
|
<Grid3x3 class="edra-gallery-placeholder-icon" />
|
||||||
class="edra-gallery-placeholder-option"
|
<span class="edra-gallery-placeholder-text">Insert a gallery</span>
|
||||||
onclick={handleDirectUpload}
|
</button>
|
||||||
onkeydown={handleKeyDown}
|
|
||||||
tabindex="0"
|
{#if showPane}
|
||||||
aria-label="Upload Images"
|
<ContentInsertionPane
|
||||||
title="Upload from device"
|
{editor}
|
||||||
>
|
position={panePosition}
|
||||||
<Upload class="edra-gallery-placeholder-icon" />
|
onClose={() => showPane = false}
|
||||||
<span class="edra-gallery-placeholder-text">Upload Images</span>
|
{deleteNode}
|
||||||
</button>
|
{albumId}
|
||||||
|
/>
|
||||||
<button
|
{/if}
|
||||||
class="edra-gallery-placeholder-option"
|
|
||||||
onclick={handleBrowseLibrary}
|
|
||||||
onkeydown={handleKeyDown}
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="Browse Media Library"
|
|
||||||
title="Choose from library"
|
|
||||||
>
|
|
||||||
<Grid class="edra-gallery-placeholder-icon" />
|
|
||||||
<span class="edra-gallery-placeholder-text">Browse Library</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden file input -->
|
|
||||||
<input
|
|
||||||
bind:this={fileInput}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
onchange={handleFileUpload}
|
|
||||||
style="display: none;"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Media Library Modal -->
|
|
||||||
<UnifiedMediaModal
|
|
||||||
bind:isOpen={isMediaLibraryOpen}
|
|
||||||
mode="multiple"
|
|
||||||
fileType="image"
|
|
||||||
onSelect={handleMediaSelect}
|
|
||||||
onClose={handleMediaLibraryClose}
|
|
||||||
/>
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '$styles/variables';
|
@import '$styles/variables';
|
||||||
|
|
||||||
.edra-gallery-placeholder-container {
|
.edra-gallery-placeholder-content {
|
||||||
display: flex;
|
width: 100%;
|
||||||
gap: $unit-2x;
|
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
|
background-color: $gray-95;
|
||||||
border: 2px dashed $gray-85;
|
border: 2px dashed $gray-85;
|
||||||
border-radius: $corner-radius;
|
border-radius: $corner-radius;
|
||||||
background: $gray-95;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: $gray-70;
|
|
||||||
background: $gray-90;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.edra-gallery-placeholder-option {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $unit;
|
justify-content: center;
|
||||||
padding: $unit-2x $unit-3x;
|
gap: $unit-2x;
|
||||||
border: 1px solid $gray-85;
|
|
||||||
border-radius: $corner-radius-sm;
|
|
||||||
background: $white;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
min-width: 140px;
|
color: $gray-50;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
background-color: $gray-90;
|
||||||
border-color: $gray-70;
|
border-color: $gray-70;
|
||||||
background: $gray-95;
|
color: $gray-40;
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
|
|
@ -209,41 +95,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.edra-gallery-placeholder-uploading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit;
|
|
||||||
padding: $unit-3x;
|
|
||||||
color: $gray-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: $unit-2x;
|
|
||||||
height: $unit-2x;
|
|
||||||
border: 2px solid $gray-90;
|
|
||||||
border-top: 2px solid $primary-color;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.edra-gallery-placeholder-icon) {
|
:global(.edra-gallery-placeholder-icon) {
|
||||||
width: $unit-3x + $unit-half;
|
width: $unit-3x;
|
||||||
height: $unit-3x + $unit-half;
|
height: $unit-3x;
|
||||||
color: $gray-50;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edra-gallery-placeholder-text {
|
.edra-gallery-placeholder-text {
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-small;
|
||||||
color: $gray-50;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue