diff --git a/prd/PRD-codebase-cleanup-refactoring.md b/prd/PRD-codebase-cleanup-refactoring.md
index e2c51dc..906d863 100644
--- a/prd/PRD-codebase-cleanup-refactoring.md
+++ b/prd/PRD-codebase-cleanup-refactoring.md
@@ -94,22 +94,22 @@ Create a consistent design system by extracting hardcoded values.
### Phase 3: Component Refactoring (Weeks 3-4)
Refactor components to reduce duplication and complexity.
-- [-] **Create base components**
+- [x] **Create base components**
- [x] Extract `BaseModal` component for shared modal logic
- [x] Create `BaseDropdown` for dropdown patterns
- [x] Merge `FormField` and `FormFieldWrapper`
- - [ ] Create `BaseSegmentedController` for shared logic
+ - [x] Create `BaseSegmentedController` for shared logic
-- [ ] **Refactor photo grids**
- - [ ] Create unified `PhotoGrid` component with `columns` prop
- - [ ] Remove 3 duplicate grid components
- - [ ] Use composition for layout variations
+- [x] **Refactor photo grids**
+ - [x] Create unified `PhotoGrid` component with `columns` prop
+ - [x] Remove 3 duplicate grid components
+ - [x] Use composition for layout variations
-- [ ] **Componentize inline SVGs**
- - [ ] Create `CloseButton` icon component
- - [ ] Create `LoadingSpinner` component
- - [ ] Create `NavigationArrow` components
- - [ ] Extract other repeated inline SVGs
+- [x] **Componentize inline SVGs**
+ - [x] Create `CloseButton` icon component
+ - [x] Create `LoadingSpinner` component (already existed)
+ - [x] Create `NavigationArrow` components (using existing arrow SVGs)
+ - [x] Extract other repeated inline SVGs (FileIcon, CopyIcon)
### Phase 4: Complex Refactoring (Weeks 5-6)
Tackle the most complex components and patterns.
diff --git a/src/lib/components/admin/EnhancedComposer.old.svelte b/src/lib/components/admin/EnhancedComposer.old.svelte
new file mode 100644
index 0000000..30abe9b
--- /dev/null
+++ b/src/lib/components/admin/EnhancedComposer.old.svelte
@@ -0,0 +1,1347 @@
+
+
+
+ {#if showToolbar && editor && !isLoading}
+
+ {/if}
+
+ {#if editor}
+
+ {#if features.tables}
+
+
+ {/if}
+ {/if}
+
+ {#if !editor}
+
+ Loading...
+
+ {/if}
+
+
focusEditor(editor, event)}
+ onkeydown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ focusEditor(editor, event)
+ }
+ }}
+ class="edra-editor"
+ class:with-toolbar={showToolbar}
+ style={`min-height: ${minHeight}px`}
+ >
+
+ {#if editor}
+
+ {/if}
+
+
+
+{#if showMediaDropdown && features.mediaLibrary}
+
+
+
+{/if}
+
+
+{#if showTextStyleDropdown}
+
+{/if}
+
+
+{#if showUrlConvertDropdown && features.urlEmbed}
+ {
+ showUrlConvertDropdown = false
+ urlConvertPos = null
+ }}
+ />
+{/if}
+
+
+{#if showLinkContextMenu && linkContextUrl}
+ {
+ showLinkContextMenu = false
+ linkContextPos = null
+ linkContextUrl = null
+ }}
+ />
+{/if}
+
+
+{#if showLinkEditDialog}
+ {
+ showLinkEditDialog = false
+ linkEditPos = null
+ linkEditUrl = ''
+ }}
+ />
+{/if}
+
+
+{#if mediaSelectionState.isOpen}
+
+{/if}
+
+
diff --git a/src/lib/components/admin/EnhancedComposer.svelte b/src/lib/components/admin/EnhancedComposer.svelte
index 30abe9b..1e6261d 100644
--- a/src/lib/components/admin/EnhancedComposer.svelte
+++ b/src/lib/components/admin/EnhancedComposer.svelte
@@ -1,1347 +1,41 @@
+
-
- {#if showToolbar && editor && !isLoading}
-
- {/if}
-
- {#if editor}
-
- {#if features.tables}
-
-
- {/if}
- {/if}
-
- {#if !editor}
-
- Loading...
-
- {/if}
-
-
focusEditor(editor, event)}
- onkeydown={(event) => {
- if (event.key === 'Enter' || event.key === ' ') {
- focusEditor(editor, event)
- }
- }}
- class="edra-editor"
- class:with-toolbar={showToolbar}
- style={`min-height: ${minHeight}px`}
- >
-
- {#if editor}
-
- {/if}
-
-
-
-{#if showMediaDropdown && features.mediaLibrary}
-
-
-
-{/if}
-
-
-{#if showTextStyleDropdown}
-
-{/if}
-
-
-{#if showUrlConvertDropdown && features.urlEmbed}
- {
- showUrlConvertDropdown = false
- urlConvertPos = null
- }}
- />
-{/if}
-
-
-{#if showLinkContextMenu && linkContextUrl}
- {
- showLinkContextMenu = false
- linkContextPos = null
- linkContextUrl = null
- }}
- />
-{/if}
-
-
-{#if showLinkEditDialog}
- {
- showLinkEditDialog = false
- linkEditPos = null
- linkEditUrl = ''
- }}
- />
-{/if}
-
-
-{#if mediaSelectionState.isOpen}
-
-{/if}
-
-
+
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/ComposerCore.svelte b/src/lib/components/admin/composer/ComposerCore.svelte
new file mode 100644
index 0000000..7d9d889
--- /dev/null
+++ b/src/lib/components/admin/composer/ComposerCore.svelte
@@ -0,0 +1,377 @@
+
+
+
+ {#if showToolbar && editor && !isLoading}
+
{
+ showTextStyleDropdown = !showTextStyleDropdown
+ textStyleDropdown.toggle()
+ }}
+ onMediaDropdownToggle={() => {
+ showMediaDropdown = !showMediaDropdown
+ mediaDropdown.toggle()
+ }}
+ />
+ {/if}
+
+ {#if editor}
+
+ {#if features.tables}
+
+
+ {/if}
+
+ {/if}
+
+ {#if !editor}
+
+ Loading...
+
+ {/if}
+
+
+
+ {#if editor}
+
+ {/if}
+
+
+
+{#if showTextStyleDropdown && editor}
+ (showTextStyleDropdown = false)}
+ />
+{/if}
+
+
+{#if showMediaDropdown && editor && features.mediaLibrary}
+ (showMediaDropdown = false)}
+ onOpenMediaLibrary={handleOpenMediaLibrary}
+ />
+{/if}
+
+
+{#if mediaSelectionState.isOpen}
+
+{/if}
+
+
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/ComposerLinkManager.svelte b/src/lib/components/admin/composer/ComposerLinkManager.svelte
new file mode 100644
index 0000000..1a7b398
--- /dev/null
+++ b/src/lib/components/admin/composer/ComposerLinkManager.svelte
@@ -0,0 +1,197 @@
+
+
+
+{#if showUrlConvertDropdown && features.urlEmbed}
+ {
+ showUrlConvertDropdown = false
+ urlConvertPos = null
+ }}
+ />
+{/if}
+
+
+{#if showLinkContextMenu && linkContextUrl}
+ {
+ showLinkContextMenu = false
+ linkContextPos = null
+ linkContextUrl = null
+ }}
+ />
+{/if}
+
+
+{#if showLinkEditDialog}
+ {
+ showLinkEditDialog = false
+ linkEditPos = null
+ linkEditUrl = ''
+ }}
+ />
+{/if}
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/ComposerMediaHandler.svelte.ts b/src/lib/components/admin/composer/ComposerMediaHandler.svelte.ts
new file mode 100644
index 0000000..79c50de
--- /dev/null
+++ b/src/lib/components/admin/composer/ComposerMediaHandler.svelte.ts
@@ -0,0 +1,180 @@
+import type { Editor } from '@tiptap/core'
+import type { Media } from '@prisma/client'
+
+export interface MediaHandlerOptions {
+ editor: Editor
+ albumId?: number
+ features: {
+ imageUpload?: boolean
+ mediaLibrary?: boolean
+ }
+}
+
+export class ComposerMediaHandler {
+ private editor: Editor
+ private albumId?: number
+ private features: MediaHandlerOptions['features']
+
+ constructor(options: MediaHandlerOptions) {
+ this.editor = options.editor
+ this.albumId = options.albumId
+ this.features = options.features
+ }
+
+ async uploadImage(file: File): Promise {
+ if (!this.editor || !this.features.imageUpload) return
+
+ // Validate 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
+ }
+
+ // Create a placeholder while uploading
+ const placeholderSrc = URL.createObjectURL(file)
+ this.editor.commands.insertContent({
+ type: 'image',
+ attrs: {
+ src: placeholderSrc,
+ alt: '',
+ title: '',
+ mediaId: null
+ }
+ })
+
+ try {
+ const auth = localStorage.getItem('admin_auth')
+ if (!auth) {
+ throw new Error('Not authenticated')
+ }
+
+ const formData = new FormData()
+ formData.append('file', file)
+
+ // Add albumId if available
+ if (this.albumId) {
+ formData.append('albumId', this.albumId.toString())
+ }
+
+ const response = await fetch('/api/media/upload', {
+ method: 'POST',
+ headers: {
+ Authorization: `Basic ${auth}`
+ },
+ body: formData
+ })
+
+ if (!response.ok) {
+ throw new Error('Upload failed')
+ }
+
+ const media = await response.json()
+
+ // Replace placeholder with actual URL
+ const displayWidth = media.width && media.width > 600 ? 600 : media.width
+
+ this.editor.commands.insertContent({
+ type: 'image',
+ attrs: {
+ src: media.url,
+ alt: media.filename || '',
+ title: media.description || '',
+ width: displayWidth,
+ height: media.height,
+ align: 'center',
+ mediaId: media.id?.toString()
+ }
+ })
+
+ // Clean up the object URL
+ URL.revokeObjectURL(placeholderSrc)
+ } catch (error) {
+ console.error('Image upload failed:', error)
+ alert('Failed to upload image. Please try again.')
+ // Remove the placeholder on error
+ this.editor.commands.undo()
+ URL.revokeObjectURL(placeholderSrc)
+ }
+ }
+
+ handleMediaSelect(media: Media): void {
+ if (!this.editor) return
+
+ // Remove placeholder if it exists
+ if (this.editor.storage.imageModal?.placeholderPos !== undefined) {
+ const pos = this.editor.storage.imageModal.placeholderPos
+ this.editor
+ .chain()
+ .focus()
+ .deleteRange({ from: pos, to: pos + 1 })
+ .run()
+ this.editor.storage.imageModal.placeholderPos = undefined
+ }
+
+ // Check if it's a video
+ const isVideo = media.mimeType?.startsWith('video/')
+
+ if (isVideo) {
+ // Insert video
+ this.editor.commands.insertContent({
+ type: 'video',
+ attrs: {
+ src: media.url,
+ title: media.description || media.filename || '',
+ mediaId: media.id.toString()
+ }
+ })
+ } else {
+ // Calculate display dimensions
+ const displayWidth = media.width && media.width > 600 ? 600 : media.width
+
+ // Insert image
+ this.editor.commands.insertContent({
+ type: 'image',
+ attrs: {
+ src: media.url,
+ alt: media.filename || '',
+ title: media.description || '',
+ width: displayWidth,
+ height: media.height,
+ align: 'center',
+ mediaId: media.id.toString()
+ }
+ })
+ }
+ }
+
+ handleMediaClose(): void {
+ // Remove the placeholder if user cancelled
+ if (this.editor && this.editor.storage.imageModal?.placeholderPos !== undefined) {
+ const pos = this.editor.storage.imageModal.placeholderPos
+ this.editor
+ .chain()
+ .focus()
+ .deleteRange({ from: pos, to: pos + 1 })
+ .run()
+ this.editor.storage.imageModal.placeholderPos = undefined
+ }
+ }
+
+ handlePasteImage(clipboardData: DataTransfer): boolean {
+ if (!this.features.imageUpload) return false
+
+ // Check for images
+ const imageItem = Array.from(clipboardData.items).find(
+ (item) => item.type.indexOf('image') === 0
+ )
+
+ if (imageItem) {
+ const file = imageItem.getAsFile()
+ if (!file) return false
+
+ // Upload the image
+ this.uploadImage(file)
+ return true // Prevent default paste behavior
+ }
+
+ return false
+ }
+}
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/ComposerToolbar.svelte b/src/lib/components/admin/composer/ComposerToolbar.svelte
new file mode 100644
index 0000000..4f349b4
--- /dev/null
+++ b/src/lib/components/admin/composer/ComposerToolbar.svelte
@@ -0,0 +1,209 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/MediaInsertDropdown.svelte b/src/lib/components/admin/composer/MediaInsertDropdown.svelte
new file mode 100644
index 0000000..8fb39d5
--- /dev/null
+++ b/src/lib/components/admin/composer/MediaInsertDropdown.svelte
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/TextStyleDropdown.svelte b/src/lib/components/admin/composer/TextStyleDropdown.svelte
new file mode 100644
index 0000000..da1c1ad
--- /dev/null
+++ b/src/lib/components/admin/composer/TextStyleDropdown.svelte
@@ -0,0 +1,134 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/editorConfig.ts b/src/lib/components/admin/composer/editorConfig.ts
new file mode 100644
index 0000000..fe4a437
--- /dev/null
+++ b/src/lib/components/admin/composer/editorConfig.ts
@@ -0,0 +1,168 @@
+import type { Editor } from '@tiptap/core'
+import type { ComposerVariant, ComposerFeatures } from './types'
+import { commands } from '$lib/components/edra/commands/commands.js'
+
+export interface FilteredCommands {
+ [key: string]: {
+ name: string
+ label: string
+ commands: any[]
+ }
+}
+
+// Get current text style for dropdown
+export function getCurrentTextStyle(editor: Editor): string {
+ if (editor.isActive('heading', { level: 1 })) return 'Heading 1'
+ if (editor.isActive('heading', { level: 2 })) return 'Heading 2'
+ if (editor.isActive('heading', { level: 3 })) return 'Heading 3'
+ if (editor.isActive('bulletList')) return 'Bullet List'
+ if (editor.isActive('orderedList')) return 'Ordered List'
+ if (editor.isActive('taskList')) return 'Task List'
+ if (editor.isActive('codeBlock')) return 'Code Block'
+ if (editor.isActive('blockquote')) return 'Blockquote'
+ return 'Paragraph'
+}
+
+// Get filtered commands based on variant and features
+export function getFilteredCommands(variant: ComposerVariant, features: ComposerFeatures): FilteredCommands {
+ const filtered = { ...commands }
+
+ // Remove groups based on variant
+ if (variant === 'minimal') {
+ delete filtered['undo-redo']
+ delete filtered['headings']
+ delete filtered['lists']
+ delete filtered['alignment']
+ delete filtered['table']
+ delete filtered['media']
+ delete filtered['fonts']
+ } else if (variant === 'inline') {
+ delete filtered['undo-redo']
+ delete filtered['headings']
+ delete filtered['lists']
+ delete filtered['alignment']
+ delete filtered['table']
+ delete filtered['media']
+ } else {
+ // Full variant - reorganize for toolbar
+ delete filtered['undo-redo']
+ delete filtered['headings'] // In text style dropdown
+ delete filtered['lists'] // In text style dropdown
+ delete filtered['alignment']
+ delete filtered['table']
+ delete filtered['media'] // In media dropdown
+ }
+
+ // Reorganize text formatting for toolbar
+ if (filtered['text-formatting']) {
+ const allCommands = filtered['text-formatting'].commands
+ const basicFormatting: any[] = []
+ const advancedFormatting: any[] = []
+
+ // Group basic formatting first
+ const basicOrder = ['bold', 'italic', 'underline', 'strike']
+ basicOrder.forEach((name) => {
+ const cmd = allCommands.find((c: any) => c.name === name)
+ if (cmd) basicFormatting.push(cmd)
+ })
+
+ // Then link and code
+ const advancedOrder = ['link', 'code']
+ advancedOrder.forEach((name) => {
+ const cmd = allCommands.find((c: any) => c.name === name)
+ if (cmd) advancedFormatting.push(cmd)
+ })
+
+ // Create two groups
+ filtered['basic-formatting'] = {
+ name: 'Basic Formatting',
+ label: 'Basic Formatting',
+ commands: basicFormatting
+ }
+
+ filtered['advanced-formatting'] = {
+ name: 'Advanced Formatting',
+ label: 'Advanced Formatting',
+ commands: advancedFormatting
+ }
+
+ // Remove original text-formatting
+ delete filtered['text-formatting']
+ }
+
+ return filtered
+}
+
+// Get media commands, but filter out based on features
+export function getMediaCommands(features: ComposerFeatures): any[] {
+ if (!commands.media) return []
+
+ let mediaCommands = [...commands.media.commands]
+
+ // Filter based on features
+ if (!features.urlEmbed) {
+ mediaCommands = mediaCommands.filter((cmd) => cmd.name !== 'iframe-placeholder')
+ }
+
+ return mediaCommands
+}
+
+// Get color commands
+export function getColorCommands(): any[] {
+ return commands.colors?.commands || []
+}
+
+// Commands to exclude from toolbar
+export const excludedCommands = ['colors', 'fonts']
+
+// Default placeholders by variant
+export function getDefaultPlaceholder(variant: ComposerVariant): string {
+ return variant === 'inline' ? "What's on your mind?" : 'Type "/" for commands...'
+}
+
+// Default min heights by variant
+export function getDefaultMinHeight(variant: ComposerVariant): number {
+ return variant === 'inline' ? 80 : 400
+}
+
+// Whether to show toolbar by default
+export function shouldShowToolbar(variant: ComposerVariant): boolean {
+ return variant === 'full'
+}
+
+// Whether to show slash commands
+export function shouldShowSlashCommands(variant: ComposerVariant): boolean {
+ return variant !== 'minimal'
+}
+
+// Default features by variant
+export function getDefaultFeatures(variant: ComposerVariant): ComposerFeatures {
+ if (variant === 'minimal') {
+ return {
+ imageUpload: false,
+ mediaLibrary: false,
+ urlEmbed: false,
+ tables: false,
+ codeBlocks: false
+ }
+ }
+
+ if (variant === 'inline') {
+ return {
+ imageUpload: true,
+ mediaLibrary: true,
+ urlEmbed: false,
+ tables: false,
+ codeBlocks: false
+ }
+ }
+
+ // Full variant
+ return {
+ imageUpload: true,
+ mediaLibrary: true,
+ urlEmbed: true,
+ tables: true,
+ codeBlocks: true
+ }
+}
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/index.ts b/src/lib/components/admin/composer/index.ts
new file mode 100644
index 0000000..fb88f8e
--- /dev/null
+++ b/src/lib/components/admin/composer/index.ts
@@ -0,0 +1,17 @@
+// Export ComposerCore as EnhancedComposer for backward compatibility
+export { default as EnhancedComposer } from './ComposerCore.svelte'
+
+// Export types
+export type { ComposerVariant, ComposerFeatures, ComposerProps } from './types'
+
+// Export individual components if needed elsewhere
+export { default as ComposerToolbar } from './ComposerToolbar.svelte'
+export { default as TextStyleDropdown } from './TextStyleDropdown.svelte'
+export { default as MediaInsertDropdown } from './MediaInsertDropdown.svelte'
+export { default as ComposerLinkManager } from './ComposerLinkManager.svelte'
+
+// Export utilities
+export { ComposerMediaHandler } from './ComposerMediaHandler.svelte'
+export { useComposerEvents } from './useComposerEvents.svelte'
+export { useDropdown } from './useDropdown.svelte'
+export * from './editorConfig'
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/types.ts b/src/lib/components/admin/composer/types.ts
new file mode 100644
index 0000000..67b637a
--- /dev/null
+++ b/src/lib/components/admin/composer/types.ts
@@ -0,0 +1,40 @@
+import type { JSONContent } from '@tiptap/core'
+
+export type ComposerVariant = 'full' | 'inline' | 'minimal'
+
+export interface ComposerFeatures {
+ imageUpload?: boolean
+ mediaLibrary?: boolean
+ urlEmbed?: boolean
+ tables?: boolean
+ codeBlocks?: boolean
+}
+
+export interface ComposerProps {
+ variant?: ComposerVariant
+ data?: JSONContent
+ onChange?: (content: JSONContent) => void
+ onCharacterCount?: (count: number) => void
+ placeholder?: string
+ minHeight?: number
+ autofocus?: boolean
+ editable?: boolean
+ class?: string
+ showToolbar?: boolean
+ showSlashCommands?: boolean
+ albumId?: number
+ features?: ComposerFeatures
+}
+
+export interface DropdownPosition {
+ top: number
+ left: number
+}
+
+export interface MediaSelectionOptions {
+ mode: 'single' | 'multiple'
+ fileType?: 'image' | 'video' | 'audio' | 'all'
+ albumId?: number
+ onSelect: (media: any) => void
+ onClose: () => void
+}
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/useComposerEvents.svelte.ts b/src/lib/components/admin/composer/useComposerEvents.svelte.ts
new file mode 100644
index 0000000..bac6b3e
--- /dev/null
+++ b/src/lib/components/admin/composer/useComposerEvents.svelte.ts
@@ -0,0 +1,138 @@
+import type { Editor } from '@tiptap/core'
+import type { ComposerMediaHandler } from './ComposerMediaHandler.svelte'
+import { focusEditor } from '$lib/components/edra/utils'
+
+export interface UseComposerEventsOptions {
+ editor: Editor | undefined
+ mediaHandler: ComposerMediaHandler | undefined
+ features: {
+ imageUpload?: boolean
+ }
+}
+
+export function useComposerEvents(options: UseComposerEventsOptions) {
+ // Handle paste events
+ function handlePaste(view: any, event: ClipboardEvent): boolean {
+ const clipboardData = event.clipboardData
+ if (!clipboardData) return false
+
+ // Let media handler check for images first
+ if (options.mediaHandler && options.features.imageUpload) {
+ const handled = options.mediaHandler.handlePasteImage(clipboardData)
+ if (handled) return true
+ }
+
+ // Handle text paste - preserve links while stripping other formatting
+ const htmlData = clipboardData.getData('text/html')
+ const plainText = clipboardData.getData('text/plain')
+
+ if (htmlData && plainText) {
+ event.preventDefault()
+
+ // Use editor commands to insert HTML content
+ const editorInstance = (view as any).editor
+ if (editorInstance) {
+ editorInstance
+ .chain()
+ .focus()
+ .insertContent(htmlData, { parseOptions: { preserveWhitespace: false } })
+ .run()
+ } else {
+ // Fallback to plain text
+ const { state, dispatch } = view
+ const { selection } = state
+ const transaction = state.tr.insertText(plainText, selection.from, selection.to)
+ dispatch(transaction)
+ }
+
+ return true
+ }
+
+ return false
+ }
+
+ // Handle editor click
+ function handleEditorClick(event: MouseEvent) {
+ if (options.editor) {
+ focusEditor(options.editor, event)
+ }
+ }
+
+ // Handle editor keyboard events
+ function handleEditorKeydown(event: KeyboardEvent) {
+ if (options.editor && (event.key === 'Enter' || event.key === ' ')) {
+ focusEditor(options.editor, event)
+ }
+ }
+
+ // Handle drag and drop for images
+ function handleDrop(view: any, event: DragEvent): boolean {
+ if (!options.features.imageUpload || !options.mediaHandler) return false
+
+ const files = event.dataTransfer?.files
+ if (!files || files.length === 0) return false
+
+ // Check if any file is an image
+ const imageFile = Array.from(files).find((file) => file.type.startsWith('image/'))
+ if (!imageFile) return false
+
+ event.preventDefault()
+
+ // Get drop position
+ const coords = { left: event.clientX, top: event.clientY }
+ const pos = view.posAtCoords(coords)
+ if (!pos) return false
+
+ // Set cursor position to drop location
+ const { state, dispatch } = view
+ const transaction = state.tr.setSelection(state.selection.constructor.near(state.doc.resolve(pos.pos)))
+ dispatch(transaction)
+
+ // Upload the image
+ options.mediaHandler.uploadImage(imageFile)
+ return true
+ }
+
+ // Handle file input for image selection
+ function handleFileSelect(event: Event) {
+ const input = event.target as HTMLInputElement
+ const file = input.files?.[0]
+ if (!file || !options.mediaHandler) return
+
+ if (file.type.startsWith('image/')) {
+ options.mediaHandler.uploadImage(file)
+ }
+
+ // Clear the input
+ input.value = ''
+ }
+
+ // Create hidden file input for image selection
+ function createFileInput(): HTMLInputElement {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = 'image/*'
+ input.style.display = 'none'
+ input.addEventListener('change', handleFileSelect)
+ document.body.appendChild(input)
+ return input
+ }
+
+ // Trigger file selection dialog
+ function selectImageFile() {
+ const input = createFileInput()
+ input.click()
+ // Clean up after a delay
+ setTimeout(() => {
+ document.body.removeChild(input)
+ }, 1000)
+ }
+
+ return {
+ handlePaste,
+ handleEditorClick,
+ handleEditorKeydown,
+ handleDrop,
+ selectImageFile
+ }
+}
\ No newline at end of file
diff --git a/src/lib/components/admin/composer/useDropdown.svelte.ts b/src/lib/components/admin/composer/useDropdown.svelte.ts
new file mode 100644
index 0000000..c4b8f5b
--- /dev/null
+++ b/src/lib/components/admin/composer/useDropdown.svelte.ts
@@ -0,0 +1,65 @@
+import type { DropdownPosition } from './types'
+
+export interface UseDropdownOptions {
+ triggerRef: HTMLElement | undefined
+ isOpen: boolean
+ onClose: () => void
+ portalClass?: string
+}
+
+export function useDropdown(options: UseDropdownOptions) {
+ let position = $state({ top: 0, left: 0 })
+
+ // Calculate dropdown position based on trigger element
+ function updatePosition() {
+ const { triggerRef } = options
+ if (triggerRef) {
+ const rect = triggerRef.getBoundingClientRect()
+ position = {
+ top: rect.bottom + 4,
+ left: rect.left
+ }
+ }
+ }
+
+ // Toggle dropdown with position update
+ function toggle() {
+ if (!options.isOpen) {
+ updatePosition()
+ }
+ // The actual toggling is handled by the parent component
+ }
+
+ // Handle click outside
+ function handleClickOutside(event: MouseEvent) {
+ const target = event.target as HTMLElement
+ const { triggerRef, portalClass, onClose } = options
+
+ const isClickInsideTrigger = triggerRef?.contains(target)
+ const isClickInsidePortal = portalClass && target.closest(`.${portalClass}`)
+
+ if (!isClickInsideTrigger && !isClickInsidePortal) {
+ onClose()
+ }
+ }
+
+ // Effect to add/remove click listener
+ $effect(() => {
+ if (options.isOpen) {
+ // Small delay to avoid immediate closure
+ setTimeout(() => {
+ document.addEventListener('click', handleClickOutside)
+ }, 0)
+
+ return () => {
+ document.removeEventListener('click', handleClickOutside)
+ }
+ }
+ })
+
+ return {
+ position: () => position,
+ updatePosition,
+ toggle
+ }
+}
\ No newline at end of file