import React, { useCallback, useEffect, useRef, useState } from 'react'; import './InsertModal.css'; interface PostSearchResult { id: string; title: string; slug: string; excerpt?: string; } interface MediaSearchResult { id: string; originalName: string; title?: string; mimeType: string; createdAt: string; } /** Get display name for media: title (truncated to 60 chars) or fallback to filename */ function getMediaDisplayName(media: MediaSearchResult): string { if (media.title) { return media.title.length > 60 ? media.title.substring(0, 60) + '...' : media.title; } return media.originalName; } type SearchResult = PostSearchResult | MediaSearchResult; type InsertMode = 'link' | 'image'; type Tab = 'external' | 'internal'; interface InsertModalProps { mode: InsertMode; onInsertLink: (url: string, text?: string) => void; onInsertImage: (url: string, alt: string, mediaId?: string) => void; onClose: () => void; initialText?: string; // Selected text in editor } function isPostResult(result: SearchResult): result is PostSearchResult { return 'title' in result; } function isMediaResult(result: SearchResult): result is MediaSearchResult { return 'originalName' in result; } export const InsertModal: React.FC = ({ mode, onInsertLink, onInsertImage, onClose, initialText = '', }) => { const [activeTab, setActiveTab] = useState('internal'); const [query, setQuery] = useState(''); const [externalUrl, setExternalUrl] = useState(''); const [externalText, setExternalText] = useState(initialText); const [externalAlt, setExternalAlt] = useState(''); const [results, setResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [isSearching, setIsSearching] = useState(false); const inputRef = useRef(null); const externalUrlRef = useRef(null); // Focus appropriate input on mount and tab change useEffect(() => { if (activeTab === 'internal') { inputRef.current?.focus(); } else { externalUrlRef.current?.focus(); } }, [activeTab]); // Debounced search effect useEffect(() => { if (activeTab !== 'internal' || query.length < 2) { setResults([]); setSelectedIndex(0); return; } const timeoutId = setTimeout(async () => { setIsSearching(true); try { if (mode === 'link') { const searchResults = await window.electronAPI.posts.search(query); setResults(searchResults || []); } else { const searchResults = await window.electronAPI.media.search(query); setResults(searchResults || []); } setSelectedIndex(0); } catch (error) { console.error('Search failed:', error); setResults([]); } finally { setIsSearching(false); } }, 300); return () => clearTimeout(timeoutId); }, [query, mode, activeTab]); // Keyboard navigation handler const handleKeyDown = useCallback((e: React.KeyboardEvent) => { switch (e.key) { case 'Escape': e.preventDefault(); onClose(); break; case 'ArrowDown': if (activeTab === 'internal') { e.preventDefault(); setSelectedIndex(prev => Math.min(prev + 1, results.length - 1)); } break; case 'ArrowUp': if (activeTab === 'internal') { e.preventDefault(); setSelectedIndex(prev => Math.max(prev - 1, 0)); } break; case 'Enter': e.preventDefault(); if (activeTab === 'internal' && results[selectedIndex]) { handleSelectResult(results[selectedIndex]); } else if (activeTab === 'external' && externalUrl) { handleExternalSubmit(); } break; case 'Tab': // Allow tab switching with Tab key when on the tab buttons break; } }, [activeTab, results, selectedIndex, externalUrl, onClose]); // Handle selecting a search result const handleSelectResult = useCallback(async (result: SearchResult) => { if (mode === 'link' && isPostResult(result)) { const linkUrl = `/posts/${result.slug}`; const linkText = initialText || result.title; onInsertLink(linkUrl, linkText); } else if (mode === 'image' && isMediaResult(result)) { // Get the media URL const url = await window.electronAPI.media.getUrl(result.id); if (url) { // Extract filename without extension for alt text const altText = result.originalName.replace(/\.[^.]+$/, ''); // Pass mediaId so the editor can link this media to the post onInsertImage(url, altText, result.id); } } onClose(); }, [mode, initialText, onInsertLink, onInsertImage, onClose]); // Handle external URL submission const handleExternalSubmit = useCallback(() => { if (!externalUrl) return; if (mode === 'link') { onInsertLink(externalUrl, externalText || undefined); } else { // External images don't have a mediaId onInsertImage(externalUrl, externalAlt || 'Image', undefined); } onClose(); }, [mode, externalUrl, externalText, externalAlt, onInsertLink, onInsertImage, onClose]); // Backdrop click handler const handleBackdropClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }, [onClose]); // Scroll selected item into view useEffect(() => { const selectedElement = document.querySelector('.insert-modal-result-item.selected'); if (selectedElement) { selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } }, [selectedIndex]); const title = mode === 'link' ? 'Insert Link' : 'Insert Image'; const internalLabel = mode === 'link' ? 'Link to Post' : 'Media Library'; const externalLabel = mode === 'link' ? 'External URL' : 'External Image'; const searchPlaceholder = mode === 'link' ? 'Search posts by title or content...' : 'Search media by name, title, or alt text...'; return (

{title}

{activeTab === 'internal' ? ( <>
setQuery(e.target.value)} autoComplete="off" />
{isSearching && (
Searching...
)} {!isSearching && query.length < 2 && (
Type at least 2 characters to search
)} {!isSearching && query.length >= 2 && results.length === 0 && (
No {mode === 'link' ? 'posts' : 'media'} found for "{query}"
)} {!isSearching && results.length > 0 && results.map((result, index) => (
handleSelectResult(result)} onMouseEnter={() => setSelectedIndex(index)} > {isPostResult(result) ? ( <>
{result.title}
{result.excerpt && (
{result.excerpt.length > 120 ? result.excerpt.substring(0, 120) + '...' : result.excerpt}
)}
/posts/{result.slug}
) : ( <>
{getMediaDisplayName(result)}
{result.mimeType} • {new Date(result.createdAt).toLocaleDateString()}
)}
))}
) : (
setExternalUrl(e.target.value)} autoComplete="off" />
{mode === 'link' ? (
setExternalText(e.target.value)} />
) : (
setExternalAlt(e.target.value)} />
)}
)}
{activeTab === 'internal' ? 'Use ↑↓ to navigate, Enter to select, Esc to close' : 'Enter URL and press Enter or click button, Esc to close'}
); };