337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
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<InsertModalProps> = ({
|
|
mode,
|
|
onInsertLink,
|
|
onInsertImage,
|
|
onClose,
|
|
initialText = '',
|
|
}) => {
|
|
const [activeTab, setActiveTab] = useState<Tab>('internal');
|
|
const [query, setQuery] = useState('');
|
|
const [externalUrl, setExternalUrl] = useState('');
|
|
const [externalText, setExternalText] = useState(initialText);
|
|
const [externalAlt, setExternalAlt] = useState('');
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const externalUrlRef = useRef<HTMLInputElement>(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 (
|
|
<div className="insert-modal-backdrop" onClick={handleBackdropClick}>
|
|
<div className="insert-modal" onKeyDown={handleKeyDown}>
|
|
<div className="insert-modal-header">
|
|
<h3 className="insert-modal-title">{title}</h3>
|
|
<div className="insert-modal-tabs">
|
|
<button
|
|
className={`insert-modal-tab ${activeTab === 'internal' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('internal')}
|
|
>
|
|
{internalLabel}
|
|
</button>
|
|
<button
|
|
className={`insert-modal-tab ${activeTab === 'external' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('external')}
|
|
>
|
|
{externalLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{activeTab === 'internal' ? (
|
|
<>
|
|
<div className="insert-modal-search">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
className="insert-modal-input"
|
|
placeholder={searchPlaceholder}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
|
|
<div className="insert-modal-results">
|
|
{isSearching && (
|
|
<div className="insert-modal-status">Searching...</div>
|
|
)}
|
|
|
|
{!isSearching && query.length < 2 && (
|
|
<div className="insert-modal-status">
|
|
Type at least 2 characters to search
|
|
</div>
|
|
)}
|
|
|
|
{!isSearching && query.length >= 2 && results.length === 0 && (
|
|
<div className="insert-modal-status">
|
|
No {mode === 'link' ? 'posts' : 'media'} found for "{query}"
|
|
</div>
|
|
)}
|
|
|
|
{!isSearching && results.length > 0 && results.map((result, index) => (
|
|
<div
|
|
key={isPostResult(result) ? result.id : result.id}
|
|
className={`insert-modal-result-item ${index === selectedIndex ? 'selected' : ''}`}
|
|
onClick={() => handleSelectResult(result)}
|
|
onMouseEnter={() => setSelectedIndex(index)}
|
|
>
|
|
{isPostResult(result) ? (
|
|
<>
|
|
<div className="insert-modal-result-title">{result.title}</div>
|
|
{result.excerpt && (
|
|
<div className="insert-modal-result-excerpt">
|
|
{result.excerpt.length > 120
|
|
? result.excerpt.substring(0, 120) + '...'
|
|
: result.excerpt}
|
|
</div>
|
|
)}
|
|
<div className="insert-modal-result-path">/posts/{result.slug}</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="insert-modal-result-title">{getMediaDisplayName(result)}</div>
|
|
<div className="insert-modal-result-meta">
|
|
{result.mimeType} • {new Date(result.createdAt).toLocaleDateString()}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="insert-modal-external">
|
|
<div className="insert-modal-field">
|
|
<label className="insert-modal-label">URL</label>
|
|
<input
|
|
ref={externalUrlRef}
|
|
type="text"
|
|
className="insert-modal-input"
|
|
placeholder={mode === 'link' ? 'https://example.com' : 'https://example.com/image.jpg'}
|
|
value={externalUrl}
|
|
onChange={(e) => setExternalUrl(e.target.value)}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
|
|
{mode === 'link' ? (
|
|
<div className="insert-modal-field">
|
|
<label className="insert-modal-label">Link Text (optional)</label>
|
|
<input
|
|
type="text"
|
|
className="insert-modal-input"
|
|
placeholder="Click here"
|
|
value={externalText}
|
|
onChange={(e) => setExternalText(e.target.value)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="insert-modal-field">
|
|
<label className="insert-modal-label">Alt Text</label>
|
|
<input
|
|
type="text"
|
|
className="insert-modal-input"
|
|
placeholder="Description of the image"
|
|
value={externalAlt}
|
|
onChange={(e) => setExternalAlt(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
className="insert-modal-submit"
|
|
onClick={handleExternalSubmit}
|
|
disabled={!externalUrl}
|
|
>
|
|
Insert {mode === 'link' ? 'Link' : 'Image'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="insert-modal-footer">
|
|
<span className="insert-modal-hint">
|
|
{activeTab === 'internal'
|
|
? 'Use ↑↓ to navigate, Enter to select, Esc to close'
|
|
: 'Enter URL and press Enter or click button, Esc to close'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|