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:
Justin Edmund 2025-06-26 16:15:17 -04:00
parent 737a34b950
commit 12c30c1501
3 changed files with 710 additions and 448 deletions

View file

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

View file

@ -1,267 +1,91 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core'
import type { Media } from '@prisma/client'
import Image from 'lucide-svelte/icons/image'
import Upload from 'lucide-svelte/icons/upload'
import { NodeViewWrapper } from 'svelte-tiptap'
import { getContext, onMount } from 'svelte'
import { mediaSelectionStore } from '$lib/stores/media-selection'
import { getContext } from 'svelte'
import ContentInsertionPane from './ContentInsertionPane.svelte'
const { editor, deleteNode }: NodeViewProps = $props()
const { editor, deleteNode, getPos }: NodeViewProps = $props()
// Get album context if available
const editorContext = getContext<any>('editorContext') || {}
const albumId = $derived(editorContext.albumId)
let fileInput: HTMLInputElement
let isUploading = $state(false)
let autoOpenModal = $state(false)
let showPane = $state(false)
let panePosition = $state({ x: 0, y: 0 })
// If configured to auto-open modal, do it on mount
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) {
function handleClick(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
// Open modal through the store
mediaSelectionStore.open({
mode: 'single',
fileType: 'image',
albumId,
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 = ''
// Get position for pane
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
panePosition = {
x: rect.left,
y: rect.bottom + 8
}
showPane = true
}
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleBrowseLibrary(e as any)
handleClick(e as any)
} else if (e.key === 'Escape') {
deleteNode()
if (showPane) {
showPane = false
} else {
deleteNode()
}
}
}
</script>
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
<div class="edra-media-placeholder-container">
{#if isUploading}
<div class="edra-media-placeholder-uploading">
<div class="spinner"></div>
<span>Uploading...</span>
</div>
{:else if !autoOpenModal}
<button
class="edra-media-placeholder-option"
onclick={handleDirectUpload}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Upload Image"
title="Upload from device"
>
<Upload class="edra-media-placeholder-icon" />
<span class="edra-media-placeholder-text">Upload Image</span>
</button>
<button
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;"
/>
<button
class="edra-media-placeholder-content"
onclick={handleClick}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Insert an image"
>
<Image class="edra-media-placeholder-icon" />
<span class="edra-media-placeholder-text">Insert an image</span>
</button>
{#if showPane}
<ContentInsertionPane
{editor}
position={panePosition}
onClose={() => showPane = false}
{deleteNode}
{albumId}
/>
{/if}
</NodeViewWrapper>
<style lang="scss">
@import '$styles/variables';
.edra-media-placeholder-container {
display: flex;
gap: $unit-2x;
.edra-media-placeholder-content {
width: 100%;
padding: $unit-3x;
background-color: $gray-95;
border: 2px dashed $gray-85;
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;
flex-direction: column;
align-items: center;
gap: $unit;
padding: $unit-2x $unit-3x;
border: 1px solid $gray-85;
border-radius: $corner-radius-sm;
background: $white;
justify-content: center;
gap: $unit-2x;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
color: $gray-50;
&:hover {
background-color: $gray-90;
border-color: $gray-70;
background: $gray-95;
transform: translateY(-1px);
color: $gray-40;
}
&: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) {
width: $unit-3x + $unit-half;
height: $unit-3x + $unit-half;
color: $gray-50;
width: $unit-3x;
height: $unit-3x;
}
.edra-media-placeholder-text {
font-size: $font-size-small;
color: $gray-50;
font-weight: 500;
}
</style>

View file

@ -1,205 +1,91 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core'
import type { Media } from '@prisma/client'
import Grid from 'lucide-svelte/icons/grid-3x3'
import Upload from 'lucide-svelte/icons/upload'
import Grid3x3 from 'lucide-svelte/icons/grid-3x3'
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()
let isMediaLibraryOpen = $state(false)
let fileInput: HTMLInputElement
let isUploading = $state(false)
// Get album context if available
const editorContext = getContext<any>('editorContext') || {}
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
e.preventDefault()
isMediaLibraryOpen = true
}
function handleDirectUpload(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
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 = ''
// Get position for pane
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
panePosition = {
x: rect.left,
y: rect.bottom + 8
}
showPane = true
}
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleBrowseLibrary(e as any)
handleClick(e as any)
} else if (e.key === 'Escape') {
deleteNode()
if (showPane) {
showPane = false
} else {
deleteNode()
}
}
}
</script>
<NodeViewWrapper class="edra-gallery-placeholder-wrapper" contenteditable="false">
<div class="edra-gallery-placeholder-container">
{#if isUploading}
<div class="edra-gallery-placeholder-uploading">
<div class="spinner"></div>
<span>Uploading images...</span>
</div>
{:else}
<button
class="edra-gallery-placeholder-option"
onclick={handleDirectUpload}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Upload Images"
title="Upload from device"
>
<Upload class="edra-gallery-placeholder-icon" />
<span class="edra-gallery-placeholder-text">Upload Images</span>
</button>
<button
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}
/>
<button
class="edra-gallery-placeholder-content"
onclick={handleClick}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Insert a gallery"
>
<Grid3x3 class="edra-gallery-placeholder-icon" />
<span class="edra-gallery-placeholder-text">Insert a gallery</span>
</button>
{#if showPane}
<ContentInsertionPane
{editor}
position={panePosition}
onClose={() => showPane = false}
{deleteNode}
{albumId}
/>
{/if}
</NodeViewWrapper>
<style lang="scss">
@import '$styles/variables';
.edra-gallery-placeholder-container {
display: flex;
gap: $unit-2x;
.edra-gallery-placeholder-content {
width: 100%;
padding: $unit-3x;
background-color: $gray-95;
border: 2px dashed $gray-85;
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;
flex-direction: column;
align-items: center;
gap: $unit;
padding: $unit-2x $unit-3x;
border: 1px solid $gray-85;
border-radius: $corner-radius-sm;
background: $white;
justify-content: center;
gap: $unit-2x;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
color: $gray-50;
&:hover {
background-color: $gray-90;
border-color: $gray-70;
background: $gray-95;
transform: translateY(-1px);
color: $gray-40;
}
&: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) {
width: $unit-3x + $unit-half;
height: $unit-3x + $unit-half;
color: $gray-50;
width: $unit-3x;
height: $unit-3x;
}
.edra-gallery-placeholder-text {
font-size: $font-size-small;
color: $gray-50;
font-weight: 500;
}
</style>