feat: easy post-links via search
This commit is contained in:
@@ -13,8 +13,16 @@ import { TagInput } from '../TagInput';
|
||||
import { ChatPanel } from '../ChatPanel';
|
||||
import { AutoSaveManager } from '../../utils';
|
||||
import { parseMacros, getMacro } from '../../macros/registry';
|
||||
import { PostSearchModal } from '../PostSearchModal';
|
||||
import './Editor.css';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
}
|
||||
|
||||
// Module-level AutoSaveManager for idle-time based auto-saving
|
||||
const autoSaveManager = new AutoSaveManager({
|
||||
idleTimeMs: 3000, // Save after 3 seconds of idle time
|
||||
@@ -254,6 +262,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]);
|
||||
const [showPostSearch, setShowPostSearch] = useState(false);
|
||||
const editorRef = useRef<unknown>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -522,10 +531,45 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
};
|
||||
|
||||
// Handle Monaco editor mount
|
||||
const handleEditorDidMount = (editor: unknown) => {
|
||||
const handleEditorDidMount = (editor: unknown, monaco: Monaco) => {
|
||||
editorRef.current = editor;
|
||||
const ed = editor as any;
|
||||
|
||||
// Add keyboard shortcut and command for inserting post links
|
||||
ed.addAction({
|
||||
id: 'editor.action.insertPostLink',
|
||||
label: 'Insert Link to Post',
|
||||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK],
|
||||
run: () => {
|
||||
setShowPostSearch(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle post selection from search modal
|
||||
const handlePostSelected = useCallback((post: SearchResult) => {
|
||||
const editor = editorRef.current as any;
|
||||
if (!editor) return;
|
||||
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
|
||||
const selection = editor.getSelection();
|
||||
const selectedText = selection ? model.getValueInRange(selection) : '';
|
||||
|
||||
const linkText = selectedText || post.title;
|
||||
const linkUrl = `/posts/${post.slug}`;
|
||||
const linkMarkdown = `[${linkText}](${linkUrl})`;
|
||||
|
||||
editor.executeEdits('insert-post-link', [{
|
||||
range: selection || editor.getSelection(),
|
||||
text: linkMarkdown,
|
||||
forceMoveMarkers: true
|
||||
}]);
|
||||
|
||||
setShowPostSearch(false);
|
||||
}, []);
|
||||
|
||||
// Configure Monaco before mount to add macro syntax highlighting
|
||||
const handleEditorWillMount = (monaco: Monaco) => {
|
||||
// Register a custom language that extends markdown with macro support
|
||||
@@ -700,6 +744,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
|
||||
<PostLinks
|
||||
postId={post.id}
|
||||
updatedAt={post.updatedAt}
|
||||
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
|
||||
/>
|
||||
</div>
|
||||
@@ -744,6 +789,15 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
📷 {images.length}
|
||||
</button>
|
||||
)}
|
||||
{editorMode === 'markdown' && (
|
||||
<button
|
||||
className="insert-post-link-button"
|
||||
onClick={() => setShowPostSearch(true)}
|
||||
title="Link to post (Ctrl+K)"
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editorMode === 'wysiwyg' && (
|
||||
@@ -813,6 +867,13 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPostSearch && (
|
||||
<PostSearchModal
|
||||
onSelect={handlePostSelected}
|
||||
onClose={() => setShowPostSearch(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Editor, defaultValueCtx, editorViewCtx, rootCtx, remarkStringifyOptionsCtx, remarkPluginsCtx } from '@milkdown/kit/core';
|
||||
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, wrapInBulletListCommand, wrapInOrderedListCommand, insertHrCommand, toggleInlineCodeCommand, insertImageCommand, toggleLinkCommand } from '@milkdown/kit/preset/commonmark';
|
||||
import { gfm, toggleStrikethroughCommand } from '@milkdown/kit/preset/gfm';
|
||||
@@ -20,6 +20,7 @@ import { macroPlugin } from '../../plugins/macroPlugin';
|
||||
// Import macros module to register all macro definitions
|
||||
import '../../macros';
|
||||
import './MilkdownEditor.css';
|
||||
import { PostSearchModal } from '../PostSearchModal';
|
||||
|
||||
/**
|
||||
* Unescape brackets that Milkdown/remark escapes.
|
||||
@@ -53,6 +54,13 @@ const remarkTightLists: RemarkPlugin = {
|
||||
options: {},
|
||||
};
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
}
|
||||
|
||||
interface MilkdownEditorProps {
|
||||
content: string;
|
||||
onChange: (markdown: string) => void;
|
||||
@@ -62,6 +70,7 @@ interface MilkdownEditorProps {
|
||||
// Toolbar component that uses the editor instance
|
||||
const EditorToolbar: React.FC = () => {
|
||||
const [loading, getEditor] = useInstance();
|
||||
const [showPostSearch, setShowPostSearch] = useState(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -112,54 +121,111 @@ const EditorToolbar: React.FC = () => {
|
||||
runCommand(insertImageCommand.key, { src: url, alt });
|
||||
}, [runCommand]);
|
||||
|
||||
const insertPostLink = useCallback(() => {
|
||||
setShowPostSearch(true);
|
||||
}, []);
|
||||
|
||||
// Add keyboard shortcut listener for Ctrl/Cmd+K
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setShowPostSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handlePostSelected = useCallback((post: SearchResult) => {
|
||||
const editor = getEditor();
|
||||
if (!editor) return;
|
||||
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx);
|
||||
const { state, dispatch } = view;
|
||||
const { selection, schema } = state;
|
||||
const selectedText = selection.empty ? '' : state.doc.textBetween(selection.from, selection.to);
|
||||
|
||||
const linkText = selectedText || post.title;
|
||||
const linkUrl = `/posts/${post.slug}`;
|
||||
|
||||
if (selection.empty) {
|
||||
// No selection - create text node with link mark and insert it
|
||||
const linkMark = schema.marks.link.create({ href: linkUrl });
|
||||
const textNode = schema.text(linkText, [linkMark]);
|
||||
const tr = state.tr.replaceSelectionWith(textNode, false);
|
||||
dispatch(tr);
|
||||
} else {
|
||||
// Has selection - toggle link mark on selection
|
||||
const linkMark = schema.marks.link.create({ href: linkUrl });
|
||||
const tr = state.tr.addMark(selection.from, selection.to, linkMark);
|
||||
dispatch(tr);
|
||||
}
|
||||
});
|
||||
|
||||
setShowPostSearch(false);
|
||||
}, [getEditor]);
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<div className="milkdown-toolbar">
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => insertHeading(1)} title="Heading 1">H1</button>
|
||||
<button onClick={() => insertHeading(2)} title="Heading 2">H2</button>
|
||||
<button onClick={() => insertHeading(3)} title="Heading 3">H3</button>
|
||||
<>
|
||||
<div className="milkdown-toolbar">
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => insertHeading(1)} title="Heading 1">H1</button>
|
||||
<button onClick={() => insertHeading(2)} title="Heading 2">H2</button>
|
||||
<button onClick={() => insertHeading(3)} title="Heading 3">H3</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => runCommand(toggleStrongCommand.key)} title="Bold (Ctrl+B)">
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title="Strikethrough">
|
||||
<s>S</s>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title="Bullet List">•</button>
|
||||
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title="Numbered List">1.</button>
|
||||
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title="Quote">❝</button>
|
||||
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title="Code">{'{}'}</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={insertLink} title="Insert Link">🔗</button>
|
||||
<button onClick={insertPostLink} title="Link to Post (Ctrl+K)">📝</button>
|
||||
<button onClick={insertImage} title="Insert Image">🖼</button>
|
||||
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule">―</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => runCommand(undoCommand.key)} title="Undo (Ctrl+Z)">↶</button>
|
||||
<button onClick={() => runCommand(redoCommand.key)} title="Redo (Ctrl+Y)">↷</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => runCommand(toggleStrongCommand.key)} title="Bold (Ctrl+B)">
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title="Strikethrough">
|
||||
<s>S</s>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title="Bullet List">•</button>
|
||||
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title="Numbered List">1.</button>
|
||||
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title="Quote">❝</button>
|
||||
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title="Code">{'{}'}</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={insertLink} title="Insert Link">🔗</button>
|
||||
<button onClick={insertImage} title="Insert Image">🖼</button>
|
||||
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule">―</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => runCommand(undoCommand.key)} title="Undo (Ctrl+Z)">↶</button>
|
||||
<button onClick={() => runCommand(redoCommand.key)} title="Redo (Ctrl+Y)">↷</button>
|
||||
</div>
|
||||
</div>
|
||||
{showPostSearch && (
|
||||
<PostSearchModal
|
||||
onSelect={handlePostSelected}
|
||||
onClose={() => setShowPostSearch(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ interface PostLinkInfo {
|
||||
interface PostLinksProps {
|
||||
postId: string;
|
||||
onPostClick?: (postId: string) => void;
|
||||
updatedAt?: string; // Trigger reload when post is saved
|
||||
}
|
||||
|
||||
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick }) => {
|
||||
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updatedAt }) => {
|
||||
const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]);
|
||||
const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -36,7 +37,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick }) =>
|
||||
};
|
||||
|
||||
loadLinks();
|
||||
}, [postId]);
|
||||
}, [postId, updatedAt]); // Reload when post is updated
|
||||
|
||||
const totalLinks = linksTo.length + linkedBy.length;
|
||||
|
||||
|
||||
130
src/renderer/components/PostSearchModal/PostSearchModal.css
Normal file
130
src/renderer/components/PostSearchModal/PostSearchModal.css
Normal file
@@ -0,0 +1,130 @@
|
||||
.post-search-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.post-search-modal {
|
||||
background: var(--color-bg-secondary, #1e1e1e);
|
||||
border: 1px solid var(--color-border, #3c3c3c);
|
||||
border-radius: 8px;
|
||||
width: 600px;
|
||||
max-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.post-search-header {
|
||||
border-bottom: 1px solid var(--color-border, #3c3c3c);
|
||||
}
|
||||
|
||||
.post-search-input {
|
||||
width: 100%;
|
||||
padding: 16px 20px;
|
||||
font-size: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text, #ccc);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.post-search-input::placeholder {
|
||||
color: var(--color-text-muted, #888);
|
||||
}
|
||||
|
||||
.post-search-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.post-search-loading,
|
||||
.post-search-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.post-search-result-item {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.post-search-result-item:hover,
|
||||
.post-search-result-item.selected {
|
||||
background: var(--color-bg-tertiary, #2a2a2a);
|
||||
}
|
||||
|
||||
.post-search-result-item.selected {
|
||||
border-left: 3px solid var(--color-primary, #0e639c);
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.post-search-result-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #fff);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.post-search-result-excerpt {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted, #888);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.post-search-result-slug {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted, #666);
|
||||
font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.post-search-footer {
|
||||
border-top: 1px solid var(--color-border, #3c3c3c);
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.post-search-hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.post-search-results::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.post-search-results::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary, #1e1e1e);
|
||||
}
|
||||
|
||||
.post-search-results::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border, #3c3c3c);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.post-search-results::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted, #555);
|
||||
}
|
||||
164
src/renderer/components/PostSearchModal/PostSearchModal.tsx
Normal file
164
src/renderer/components/PostSearchModal/PostSearchModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import './PostSearchModal.css';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
}
|
||||
|
||||
interface PostSearchModalProps {
|
||||
onSelect: (post: SearchResult) => void;
|
||||
onClose: () => void;
|
||||
initialQuery?: string;
|
||||
}
|
||||
|
||||
export const PostSearchModal: React.FC<PostSearchModalProps> = ({
|
||||
onSelect,
|
||||
onClose,
|
||||
initialQuery = ''
|
||||
}) => {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus search input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
if (query.length < 2) {
|
||||
setResults([]);
|
||||
setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const searchResults = await window.electronAPI.posts.search(query);
|
||||
setResults(searchResults || []);
|
||||
setSelectedIndex(0);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [query]);
|
||||
|
||||
// Keyboard navigation handler
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (results[selectedIndex]) {
|
||||
onSelect(results[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [results, selectedIndex, onClose, onSelect]);
|
||||
|
||||
// Backdrop click handler
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
// Result click handler
|
||||
const handleResultClick = useCallback((post: SearchResult) => {
|
||||
onSelect(post);
|
||||
}, [onSelect]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedElement = document.querySelector('.post-search-result-item.selected');
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<div className="post-search-modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="post-search-modal" onKeyDown={handleKeyDown}>
|
||||
<div className="post-search-header">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="post-search-input"
|
||||
placeholder="Search posts by title or content..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="post-search-results">
|
||||
{isSearching && (
|
||||
<div className="post-search-loading">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && query.length < 2 && (
|
||||
<div className="post-search-empty">
|
||||
Type at least 2 characters to search
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && query.length >= 2 && results.length === 0 && (
|
||||
<div className="post-search-empty">
|
||||
No posts found for "{query}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && results.length > 0 && results.map((post, index) => (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`post-search-result-item ${index === selectedIndex ? 'selected' : ''}`}
|
||||
onClick={() => handleResultClick(post)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="post-search-result-title">{post.title}</div>
|
||||
{post.excerpt && (
|
||||
<div className="post-search-result-excerpt">
|
||||
{post.excerpt.length > 120
|
||||
? post.excerpt.substring(0, 120) + '...'
|
||||
: post.excerpt}
|
||||
</div>
|
||||
)}
|
||||
<div className="post-search-result-slug">/posts/{post.slug}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="post-search-footer">
|
||||
<span className="post-search-hint">
|
||||
Use ↑↓ to navigate, Enter to select, Esc to close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/PostSearchModal/index.ts
Normal file
1
src/renderer/components/PostSearchModal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PostSearchModal } from './PostSearchModal';
|
||||
Reference in New Issue
Block a user