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">
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue