feat: Add semantic search with vector embeddings

Add vector-based semantic search to complement keyword search.
  Users can toggle between "Keyword" and "Semantic" modes in the
  search modal (Cmd+K, then Tab to switch).

  Semantic search:
  - Uses OpenAI text-embedding-ada-002 (1536 dimensions)
  - Finds content by meaning, not exact words
  - Shows similarity scores as percentages
  - ~300ms latency, ~$0.0001/query
  - Graceful fallback if OPENAI_API_KEY not set

  New files:
  - convex/embeddings.ts - Embedding generation actions
  - convex/embeddingsQueries.ts - Queries/mutations for embeddings
  - convex/semanticSearch.ts - Vector search action
  - convex/semanticSearchQueries.ts - Result hydration queries
  - content/pages/docs-search.md - Keyword search docs
  - content/pages/docs-semantic-search.md - Semantic search docs

  Changes:
  - convex/schema.ts: Add embedding field and by_embedding vectorIndex
  - SearchModal.tsx: Add mode toggle (TextAa/Brain icons)
  - sync-posts.ts: Generate embeddings after content sync
  - global.css: Search mode toggle styles

  Documentation updated:
  - changelog.md, TASK.md, files.md, about.md, home.md

  Configuration:
  npx convex env set OPENAI_API_KEY sk-your-key

  Generated with [Claude Code](https://claude.com/claude-code)

  Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

  Status: Ready to commit. All semantic search files are staged. The TypeScript warnings are pre-existing (unused variables) and don't affect the build.
This commit is contained in:
Wayne Sutton
2026-01-05 18:30:48 -08:00
parent 83411ec1b2
commit 5a8df46681
58 changed files with 7024 additions and 2527 deletions

View File

@@ -1,32 +1,94 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "convex/react";
import { useQuery, useAction } from "convex/react";
import { api } from "../../convex/_generated/api";
import { MagnifyingGlass, X, FileText, Article, ArrowRight } from "@phosphor-icons/react";
import {
MagnifyingGlass,
X,
FileText,
Article,
ArrowRight,
TextAa,
Brain,
} from "@phosphor-icons/react";
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
}
type SearchMode = "keyword" | "semantic";
interface SearchResult {
_id: string;
type: "post" | "page";
slug: string;
title: string;
description?: string;
snippet: string;
score?: number;
anchor?: string;
}
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [searchMode, setSearchMode] = useState<SearchMode>("keyword");
const [semanticResults, setSemanticResults] = useState<SearchResult[] | null>(null);
const [isSemanticSearching, setIsSemanticSearching] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
// Fetch search results from Convex
const results = useQuery(
// Keyword search (reactive query)
const keywordResults = useQuery(
api.search.search,
searchQuery.trim() ? { query: searchQuery } : "skip"
searchMode === "keyword" && searchQuery.trim() ? { query: searchQuery } : "skip"
);
// Semantic search action
const semanticSearchAction = useAction(api.semanticSearch.semanticSearch);
// Trigger semantic search with debounce
useEffect(() => {
if (searchMode !== "semantic" || !searchQuery.trim()) {
setSemanticResults(null);
setIsSemanticSearching(false);
return;
}
setIsSemanticSearching(true);
const timeoutId = setTimeout(async () => {
try {
const results = await semanticSearchAction({ query: searchQuery });
setSemanticResults(results as SearchResult[]);
} catch (error) {
console.error("Semantic search error:", error);
setSemanticResults([]);
} finally {
setIsSemanticSearching(false);
}
}, 300); // 300ms debounce for API calls
return () => clearTimeout(timeoutId);
}, [searchQuery, searchMode, semanticSearchAction]);
// Get current results based on mode
const results: SearchResult[] | undefined =
searchMode === "keyword"
? (keywordResults as SearchResult[] | undefined)
: (semanticResults ?? undefined);
const isLoading =
searchMode === "keyword"
? keywordResults === undefined && searchQuery.trim() !== ""
: isSemanticSearching;
// Focus input when modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
setSearchQuery("");
setSelectedIndex(0);
setSemanticResults(null);
}
}, [isOpen]);
@@ -38,6 +100,21 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Tab toggles between search modes
if (e.key === "Tab") {
e.preventDefault();
setSearchMode((prev) => (prev === "keyword" ? "semantic" : "keyword"));
return;
}
// Escape closes modal
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
// Arrow/Enter only work when there are results
if (!results || results.length === 0) return;
switch (e.key) {
@@ -53,25 +130,28 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
e.preventDefault();
if (results[selectedIndex]) {
const result = results[selectedIndex];
// Pass search query as URL param for highlighting on destination page
const url = `/${result.slug}?q=${encodeURIComponent(searchQuery)}`;
// Only pass query param for keyword search (highlighting)
// Semantic search doesn't match exact words
const url =
searchMode === "keyword"
? `/${result.slug}?q=${encodeURIComponent(searchQuery)}`
: `/${result.slug}`;
navigate(url);
onClose();
}
break;
case "Escape":
e.preventDefault();
onClose();
break;
}
},
[results, selectedIndex, navigate, onClose]
[results, selectedIndex, navigate, onClose, searchMode, searchQuery]
);
// Handle clicking on a result
const handleResultClick = (slug: string) => {
// Pass search query as URL param for highlighting on destination page
const url = `/${slug}?q=${encodeURIComponent(searchQuery)}`;
// Only pass query param for keyword search (highlighting)
const url =
searchMode === "keyword"
? `/${slug}?q=${encodeURIComponent(searchQuery)}`
: `/${slug}`;
navigate(url);
onClose();
};
@@ -88,6 +168,26 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
return (
<div className="search-modal-backdrop" onClick={handleBackdropClick}>
<div className="search-modal">
{/* Search mode toggle */}
<div className="search-mode-toggle">
<button
className={`search-mode-btn ${searchMode === "keyword" ? "active" : ""}`}
onClick={() => setSearchMode("keyword")}
title="Keyword search - matches exact words"
>
<TextAa size={16} weight="bold" />
<span>Keyword</span>
</button>
<button
className={`search-mode-btn ${searchMode === "semantic" ? "active" : ""}`}
onClick={() => setSearchMode("semantic")}
title="Semantic search - finds similar meaning"
>
<Brain size={16} weight="bold" />
<span>Semantic</span>
</button>
</div>
{/* Search input */}
<div className="search-modal-input-wrapper">
<MagnifyingGlass size={20} className="search-modal-icon" weight="bold" />
@@ -97,7 +197,11 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search posts and pages..."
placeholder={
searchMode === "keyword"
? "Search posts and pages..."
: "Describe what you're looking for..."
}
className="search-modal-input"
autoComplete="off"
autoCorrect="off"
@@ -113,10 +217,18 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
<div className="search-modal-results">
{searchQuery.trim() === "" ? (
<div className="search-modal-hint">
<p>Type to search posts and pages</p>
<p>
{searchMode === "keyword"
? "Type to search posts and pages"
: "Describe what you're looking for"}
</p>
<div className="search-modal-shortcuts">
<span className="search-shortcut">
<kbd></kbd><kbd></kbd> Navigate
<kbd>Tab</kbd> Switch mode
</span>
<span className="search-shortcut">
<kbd></kbd>
<kbd></kbd> Navigate
</span>
<span className="search-shortcut">
<kbd></kbd> Select
@@ -126,13 +238,20 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
</span>
</div>
</div>
) : results === undefined ? (
<div className="search-modal-loading">Searching...</div>
) : results.length === 0 ? (
) : isLoading ? (
<div className="search-modal-loading">
{searchMode === "semantic" ? "Finding similar content..." : "Searching..."}
</div>
) : results && results.length === 0 ? (
<div className="search-modal-empty">
No results found for "{searchQuery}"
{searchMode === "semantic" && (
<p className="search-modal-empty-hint">
Try keyword search for exact matches
</p>
)}
</div>
) : (
) : results ? (
<ul className="search-results-list">
{results.map((result, index) => (
<li key={result._id}>
@@ -152,25 +271,36 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
<div className="search-result-title">{result.title}</div>
<div className="search-result-snippet">{result.snippet}</div>
</div>
<div className="search-result-type">
{result.type === "post" ? "Post" : "Page"}
<div className="search-result-meta">
<span className="search-result-type">
{result.type === "post" ? "Post" : "Page"}
</span>
{searchMode === "semantic" && result.score !== undefined && (
<span className="search-result-score">
{Math.round(result.score * 100)}%
</span>
)}
</div>
<ArrowRight size={16} className="search-result-arrow" weight="bold" />
</button>
</li>
))}
</ul>
)}
) : null}
</div>
{/* Footer with keyboard hints */}
{results && results.length > 0 && (
<div className="search-modal-footer">
<span className="search-footer-hint">
<kbd></kbd> to select
<kbd>Tab</kbd> switch mode
</span>
<span className="search-footer-hint">
<kbd></kbd><kbd></kbd> to navigate
<kbd></kbd> select
</span>
<span className="search-footer-hint">
<kbd></kbd>
<kbd></kbd> navigate
</span>
</div>
)}
@@ -178,4 +308,3 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
</div>
);
}

View File

@@ -10,6 +10,10 @@ import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";
import TurndownService from "turndown";
import Showdown from "showdown";
import {
ArrowLeft,
Article,
@@ -34,7 +38,6 @@ import {
Clock,
Link as LinkIcon,
Copy,
ArrowClockwise,
Terminal,
CheckCircle,
Warning,
@@ -58,6 +61,7 @@ import {
CaretDown,
ArrowsOut,
ArrowsIn,
FloppyDisk,
} from "@phosphor-icons/react";
import siteConfig from "../config/siteConfig";
import AIChatView from "../components/AIChatView";
@@ -264,6 +268,110 @@ function CommandModal({
);
}
// Confirm Delete modal component
interface ConfirmDeleteModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
itemName: string;
itemType: "post" | "page";
isDeleting: boolean;
}
function ConfirmDeleteModal({
isOpen,
onClose,
onConfirm,
title,
itemName,
itemType,
isDeleting,
}: ConfirmDeleteModalProps) {
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isDeleting) {
onClose();
}
};
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isDeleting) {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEsc);
}
return () => document.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose, isDeleting]);
if (!isOpen) return null;
return (
<div className="dashboard-modal-backdrop" onClick={handleBackdropClick}>
<div className="dashboard-modal dashboard-modal-delete">
<div className="dashboard-modal-header">
<div className="dashboard-modal-icon dashboard-modal-icon-warning">
<Warning size={24} weight="fill" />
</div>
<h3 className="dashboard-modal-title">{title}</h3>
<button
className="dashboard-modal-close"
onClick={onClose}
disabled={isDeleting}
>
<X size={18} weight="bold" />
</button>
</div>
<div className="dashboard-modal-content">
<p className="dashboard-modal-message">
Are you sure you want to delete this {itemType}?
</p>
<div className="dashboard-modal-item-name">
<FileText size={18} />
<span>{itemName}</span>
</div>
<p className="dashboard-modal-warning-text">
This action cannot be undone. The {itemType} will be permanently
removed from the database.
</p>
</div>
<div className="dashboard-modal-footer">
<div className="dashboard-modal-actions">
<button
className="dashboard-modal-btn secondary"
onClick={onClose}
disabled={isDeleting}
>
Cancel
</button>
<button
className="dashboard-modal-btn danger"
onClick={onConfirm}
disabled={isDeleting}
>
{isDeleting ? (
<>
<SpinnerGap size={16} className="animate-spin" />
<span>Deleting...</span>
</>
) : (
<>
<Trash size={16} />
<span>Delete {itemType}</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
);
}
// Dashboard sections
type DashboardSection =
| "posts"
@@ -301,6 +409,7 @@ interface ContentItem {
authorName?: string;
authorImage?: string;
order?: number;
source?: "dashboard" | "sync";
}
// Frontmatter fields for posts
@@ -551,6 +660,15 @@ function DashboardContent() {
description?: string;
}>({ isOpen: false, title: "", command: "" });
// Delete confirmation modal state
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean;
id: string;
title: string;
type: "post" | "page";
}>({ isOpen: false, id: "", title: "", type: "post" });
const [isDeleting, setIsDeleting] = useState(false);
// Sync server state
const [syncOutput, setSyncOutput] = useState<string>("");
const [syncRunning, setSyncRunning] = useState<string | null>(null); // command id or null
@@ -563,6 +681,12 @@ function DashboardContent() {
const posts = useQuery(api.posts.listAll);
const pages = useQuery(api.pages.listAll);
// CMS mutations for CRUD operations
const deletePostMutation = useMutation(api.cms.deletePost);
const deletePageMutation = useMutation(api.cms.deletePage);
const updatePostMutation = useMutation(api.cms.updatePost);
const updatePageMutation = useMutation(api.cms.updatePage);
// Add toast notification
const addToast = useCallback((message: string, type: ToastType = "info") => {
const id = crypto.randomUUID();
@@ -732,6 +856,123 @@ function DashboardContent() {
setActiveSection("page-editor");
}, []);
// Show delete confirmation modal for a post
const handleDeletePost = useCallback(
(id: string, title: string) => {
setDeleteModal({
isOpen: true,
id,
title,
type: "post",
});
},
[],
);
// Show delete confirmation modal for a page
const handleDeletePage = useCallback(
(id: string, title: string) => {
setDeleteModal({
isOpen: true,
id,
title,
type: "page",
});
},
[],
);
// Close delete modal
const closeDeleteModal = useCallback(() => {
if (!isDeleting) {
setDeleteModal({ isOpen: false, id: "", title: "", type: "post" });
}
}, [isDeleting]);
// Confirm and execute deletion
const confirmDelete = useCallback(async () => {
setIsDeleting(true);
try {
if (deleteModal.type === "post") {
await deletePostMutation({ id: deleteModal.id as Id<"posts"> });
addToast("Post deleted successfully", "success");
} else {
await deletePageMutation({ id: deleteModal.id as Id<"pages"> });
addToast("Page deleted successfully", "success");
}
setDeleteModal({ isOpen: false, id: "", title: "", type: "post" });
} catch (error) {
addToast(
error instanceof Error ? error.message : `Failed to delete ${deleteModal.type}`,
"error",
);
} finally {
setIsDeleting(false);
}
}, [deleteModal, deletePostMutation, deletePageMutation, addToast]);
// Handle saving post changes
const handleSavePost = useCallback(
async (item: ContentItem) => {
try {
await updatePostMutation({
id: item._id as Id<"posts">,
post: {
title: item.title,
description: item.description,
content: item.content,
date: item.date,
published: item.published,
tags: item.tags,
excerpt: item.excerpt,
image: item.image,
featured: item.featured,
featuredOrder: item.featuredOrder,
authorName: item.authorName,
authorImage: item.authorImage,
},
});
addToast("Post saved successfully", "success");
} catch (error) {
addToast(
error instanceof Error ? error.message : "Failed to save post",
"error",
);
}
},
[updatePostMutation, addToast],
);
// Handle saving page changes
const handleSavePage = useCallback(
async (item: ContentItem) => {
try {
await updatePageMutation({
id: item._id as Id<"pages">,
page: {
title: item.title,
content: item.content,
published: item.published,
order: item.order,
excerpt: item.excerpt,
image: item.image,
featured: item.featured,
featuredOrder: item.featuredOrder,
authorName: item.authorName,
authorImage: item.authorImage,
},
});
addToast("Page saved successfully", "success");
} catch (error) {
addToast(
error instanceof Error ? error.message : "Failed to save page",
"error",
);
}
},
[updatePageMutation, addToast],
);
// Generate markdown content from item
const generateMarkdown = useCallback(
(item: ContentItem, type: "post" | "page"): string => {
@@ -899,6 +1140,17 @@ function DashboardContent() {
description={commandModal.description}
/>
{/* Delete Confirmation Modal */}
<ConfirmDeleteModal
isOpen={deleteModal.isOpen}
onClose={closeDeleteModal}
onConfirm={confirmDelete}
title="Delete Confirmation"
itemName={deleteModal.title}
itemType={deleteModal.type}
isDeleting={isDeleting}
/>
{/* Left Sidebar */}
<aside
className={`dashboard-sidebar-left ${sidebarCollapsed ? "collapsed" : ""}`}
@@ -1098,6 +1350,7 @@ function DashboardContent() {
posts={filteredPosts}
onEdit={handleEditPost}
searchQuery={searchQuery}
onDelete={handleDeletePost}
/>
)}
@@ -1107,6 +1360,7 @@ function DashboardContent() {
pages={filteredPages}
onEdit={handleEditPage}
searchQuery={searchQuery}
onDelete={handleDeletePage}
/>
)}
@@ -1125,6 +1379,9 @@ function DashboardContent() {
onBack={() =>
setActiveSection(editingType === "post" ? "posts" : "pages")
}
onSave={
editingType === "post" ? handleSavePost : handleSavePage
}
/>
)}
@@ -1134,6 +1391,7 @@ function DashboardContent() {
contentType="post"
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
addToast={addToast}
/>
)}
@@ -1143,6 +1401,7 @@ function DashboardContent() {
contentType="page"
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
addToast={addToast}
/>
)}
@@ -1172,7 +1431,7 @@ function DashboardContent() {
{/* Import URL */}
{activeSection === "import" && (
<ImportURLSection showCommandModal={showCommandModal} />
<ImportURLSection addToast={addToast} />
)}
{/* Site Config */}
@@ -1214,10 +1473,12 @@ function PostsListView({
posts,
onEdit,
searchQuery,
onDelete,
}: {
posts: ContentItem[];
onEdit: (post: ContentItem) => void;
searchQuery: string;
onDelete: (id: string, title: string) => void;
}) {
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
const [itemsPerPage, setItemsPerPage] = useState(15);
@@ -1339,6 +1600,12 @@ function PostsListView({
>
{post.published ? "Published" : "Draft"}
</span>
{post.source === "dashboard" && (
<span className="source-badge dashboard">Dashboard</span>
)}
{(!post.source || post.source === "sync") && (
<span className="source-badge sync">Synced</span>
)}
</div>
<div className="col-actions">
<button
@@ -1356,6 +1623,15 @@ function PostsListView({
>
<Eye size={16} />
</Link>
{post.source === "dashboard" && (
<button
className="action-btn delete"
onClick={() => onDelete(post._id, post.title)}
title="Delete"
>
<Trash size={16} />
</button>
)}
</div>
</div>
))
@@ -1392,10 +1668,12 @@ function PagesListView({
pages,
onEdit,
searchQuery,
onDelete,
}: {
pages: ContentItem[];
onEdit: (page: ContentItem) => void;
searchQuery: string;
onDelete: (id: string, title: string) => void;
}) {
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
const [itemsPerPage, setItemsPerPage] = useState(15);
@@ -1515,6 +1793,12 @@ function PagesListView({
>
{page.published ? "Published" : "Draft"}
</span>
{page.source === "dashboard" && (
<span className="source-badge dashboard">Dashboard</span>
)}
{(!page.source || page.source === "sync") && (
<span className="source-badge sync">Synced</span>
)}
</div>
<div className="col-actions">
<button
@@ -1532,6 +1816,15 @@ function PagesListView({
>
<Eye size={16} />
</Link>
{page.source === "dashboard" && (
<button
className="action-btn delete"
onClick={() => onDelete(page._id, page.title)}
title="Delete"
>
<Trash size={16} />
</button>
)}
</div>
</div>
))
@@ -1573,6 +1866,7 @@ function EditorView({
onDownload,
onCopy,
onBack,
onSave,
}: {
item: ContentItem;
type: "post" | "page";
@@ -1582,8 +1876,10 @@ function EditorView({
onDownload: () => void;
onCopy: () => void;
onBack: () => void;
onSave: (item: ContentItem) => Promise<void>;
}) {
const [copied, setCopied] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("dashboard-sidebar-width");
return saved ? Number(saved) : 280;
@@ -1597,6 +1893,15 @@ function EditorView({
setTimeout(() => setCopied(false), 2000);
};
const handleSave = async () => {
setIsSaving(true);
try {
await onSave(item);
} finally {
setIsSaving(false);
}
};
const startXRef = useRef(0);
const startWidthRef = useRef(0);
@@ -1679,6 +1984,19 @@ function EditorView({
<Download size={16} />
<span>Download .md</span>
</button>
<button
className="dashboard-action-btn success"
onClick={handleSave}
disabled={isSaving}
title="Save to Database"
>
{isSaving ? (
<SpinnerGap size={16} className="animate-spin" />
) : (
<FloppyDisk size={16} />
)}
<span>{isSaving ? "Saving..." : "Save"}</span>
</button>
</div>
</div>
@@ -2173,12 +2491,18 @@ function WriteSection({
contentType,
sidebarCollapsed,
setSidebarCollapsed,
addToast,
}: {
contentType: "post" | "page";
sidebarCollapsed: boolean;
setSidebarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
addToast: (message: string, type?: ToastType) => void;
}) {
const [content, setContent] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [editorMode, setEditorMode] = useState<"markdown" | "richtext" | "preview">("markdown");
const createPostMutation = useMutation(api.cms.createPost);
const createPageMutation = useMutation(api.cms.createPage);
const [copied, setCopied] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [focusMode, setFocusMode] = useState(() => {
@@ -2223,6 +2547,75 @@ function WriteSection({
});
}, []);
// HTML <-> Markdown converters
const turndownService = useMemo(() => {
const service = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
});
return service;
}, []);
const showdownConverter = useMemo(() => {
const converter = new Showdown.Converter({
tables: true,
strikethrough: true,
tasklists: true,
});
return converter;
}, []);
// Convert between modes - extract body content for rich text editing
const getBodyContent = useCallback((fullContent: string): string => {
const frontmatterMatch = fullContent.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return frontmatterMatch ? frontmatterMatch[1].trim() : fullContent;
}, []);
const getFrontmatter = useCallback((fullContent: string): string => {
const frontmatterMatch = fullContent.match(/^(---\n[\s\S]*?\n---\n?)/);
return frontmatterMatch ? frontmatterMatch[1] : "";
}, []);
// State for rich text HTML content
const [richTextHtml, setRichTextHtml] = useState("");
// Handle mode changes with content conversion
const handleModeChange = useCallback(
(newMode: "markdown" | "richtext" | "preview") => {
if (newMode === editorMode) return;
if (newMode === "richtext" && editorMode === "markdown") {
// Converting from markdown to rich text
const bodyContent = getBodyContent(content);
const html = showdownConverter.makeHtml(bodyContent);
setRichTextHtml(html);
} else if (newMode === "markdown" && editorMode === "richtext") {
// Converting from rich text back to markdown
const markdown = turndownService.turndown(richTextHtml);
const frontmatter = getFrontmatter(content);
setContent(frontmatter + markdown);
}
setEditorMode(newMode);
},
[editorMode, content, richTextHtml, getBodyContent, getFrontmatter, showdownConverter, turndownService]
);
// Quill modules configuration
const quillModules = useMemo(
() => ({
toolbar: [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "strike"],
["blockquote", "code-block"],
[{ list: "ordered" }, { list: "bullet" }],
["link"],
["clean"],
],
}),
[]
);
// Keyboard shortcut: Escape to exit focus mode
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -2362,6 +2755,121 @@ published: false
URL.revokeObjectURL(url);
}, [content, contentType]);
// Parse frontmatter and save to database
const handleSaveToDb = useCallback(async () => {
setIsSaving(true);
try {
// Parse frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!frontmatterMatch) {
addToast("Content must have valid frontmatter (---)", "error");
return;
}
const frontmatterText = frontmatterMatch[1];
const bodyContent = frontmatterMatch[2].trim();
// Parse frontmatter fields
const parseValue = (key: string): string | undefined => {
const match = frontmatterText.match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+)["']?`, "m"));
return match ? match[1].trim() : undefined;
};
const parseBool = (key: string): boolean | undefined => {
const match = frontmatterText.match(new RegExp(`^${key}:\\s*(true|false)`, "m"));
return match ? match[1] === "true" : undefined;
};
const parseNumber = (key: string): number | undefined => {
const match = frontmatterText.match(new RegExp(`^${key}:\\s*(\\d+)`, "m"));
return match ? parseInt(match[1], 10) : undefined;
};
const parseTags = (): string[] => {
const match = frontmatterText.match(/^tags:\s*\[(.*?)\]/m);
if (match) {
return match[1].split(",").map((t) => t.trim().replace(/["']/g, "")).filter(Boolean);
}
return [];
};
const title = parseValue("title");
const slug = parseValue("slug");
if (!title || !slug) {
addToast("Frontmatter must include title and slug", "error");
return;
}
if (contentType === "post") {
const description = parseValue("description") || "";
const date = parseValue("date") || new Date().toISOString().split("T")[0];
const published = parseBool("published") ?? false;
const tags = parseTags();
const readTime = parseValue("readTime");
const image = parseValue("image");
const excerpt = parseValue("excerpt");
const featured = parseBool("featured");
const featuredOrder = parseNumber("featuredOrder");
const authorName = parseValue("authorName");
const authorImage = parseValue("authorImage");
await createPostMutation({
post: {
slug,
title,
description,
content: bodyContent,
date,
published,
tags,
readTime,
image,
excerpt,
featured,
featuredOrder,
authorName,
authorImage,
},
});
addToast(`Post "${title}" saved to database`, "success");
} else {
const published = parseBool("published") ?? false;
const order = parseNumber("order");
const showInNav = parseBool("showInNav");
const excerpt = parseValue("excerpt");
const image = parseValue("image");
const featured = parseBool("featured");
const featuredOrder = parseNumber("featuredOrder");
const authorName = parseValue("authorName");
const authorImage = parseValue("authorImage");
await createPageMutation({
page: {
slug,
title,
content: bodyContent,
published,
order,
showInNav,
excerpt,
image,
featured,
featuredOrder,
authorName,
authorImage,
},
});
addToast(`Page "${title}" saved to database`, "success");
}
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save";
addToast(message, "error");
} finally {
setIsSaving(false);
}
}, [content, contentType, createPostMutation, createPageMutation, addToast]);
// Calculate stats
const lines = content.split("\n").length;
const characters = content.length;
@@ -2377,6 +2885,26 @@ published: false
<div className="dashboard-write-header">
<div className="dashboard-write-title">
<span>{contentType === "post" ? "Blog Post" : "Page"}</span>
<div className="dashboard-editor-mode-toggles">
<button
className={`dashboard-view-toggle ${editorMode === "markdown" ? "active" : ""}`}
onClick={() => handleModeChange("markdown")}
>
Markdown
</button>
<button
className={`dashboard-view-toggle ${editorMode === "richtext" ? "active" : ""}`}
onClick={() => handleModeChange("richtext")}
>
Rich Text
</button>
<button
className={`dashboard-view-toggle ${editorMode === "preview" ? "active" : ""}`}
onClick={() => handleModeChange("preview")}
>
Preview
</button>
</div>
</div>
<div className="dashboard-write-actions">
<button
@@ -2406,6 +2934,19 @@ published: false
<Download size={16} />
<span>Download .md</span>
</button>
<button
onClick={handleSaveToDb}
disabled={isSaving}
className="dashboard-action-btn success"
title="Save to Database"
>
{isSaving ? (
<SpinnerGap size={16} className="animate-spin" />
) : (
<FloppyDisk size={16} />
)}
<span>{isSaving ? "Saving..." : "Save to DB"}</span>
</button>
<button
onClick={toggleFocusMode}
className={`dashboard-action-btn focus-toggle ${focusMode ? "active" : ""}`}
@@ -2423,13 +2964,43 @@ published: false
<div className="dashboard-write-container">
{/* Main Writing Area */}
<div className="dashboard-write-main">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="dashboard-write-textarea"
placeholder="Start writing your markdown..."
spellCheck={true}
/>
{editorMode === "markdown" && (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="dashboard-write-textarea"
placeholder="Start writing your markdown..."
spellCheck={true}
/>
)}
{editorMode === "richtext" && (
<div className="dashboard-quill-container">
<ReactQuill
theme="snow"
value={richTextHtml}
onChange={setRichTextHtml}
modules={quillModules}
placeholder="Start writing..."
/>
</div>
)}
{editorMode === "preview" && (
<div className="dashboard-preview">
<div className="dashboard-preview-content">
<div className="blog-post-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}
>
{getBodyContent(content)}
</ReactMarkdown>
</div>
</div>
</div>
)}
<div className="dashboard-write-footer">
<div className="dashboard-write-stats">
<span>{words} words</span>
@@ -2439,9 +3010,10 @@ published: false
<span>{characters} chars</span>
</div>
<div className="dashboard-write-hint">
Save to{" "}
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
then <code>npm run sync</code>
{editorMode === "richtext"
? "Editing body content only (frontmatter preserved)"
: <>Save to{" "}<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}then <code>npm run sync</code></>
}
</div>
</div>
</div>
@@ -3424,30 +3996,45 @@ function NewsletterStatsSection() {
}
function ImportURLSection({
showCommandModal,
addToast,
}: {
showCommandModal: (
title: string,
command: string,
description?: string,
) => void;
addToast: (message: string, type?: ToastType) => void;
}) {
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [publishImmediately, setPublishImmediately] = useState(false);
const [lastImported, setLastImported] = useState<{
title: string;
slug: string;
} | null>(null);
const importAction = useAction(api.importAction.importFromUrl);
const handleImport = async () => {
if (!url.trim()) return;
setIsLoading(true);
setLastImported(null);
// Show the command modal with the import command
showCommandModal(
"Import URL",
`npm run import "${url.trim()}"`,
"Copy this command and run it in your terminal to import the article",
);
try {
const result = await importAction({
url: url.trim(),
published: publishImmediately,
});
setIsLoading(false);
if (result.success && result.slug && result.title) {
setLastImported({ title: result.title, slug: result.slug });
addToast(`Imported "${result.title}" successfully`, "success");
setUrl("");
} else {
addToast(result.error || "Failed to import URL", "error");
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to import URL";
addToast(message, "error");
} finally {
setIsLoading(false);
}
};
return (
@@ -3455,7 +4042,7 @@ function ImportURLSection({
<div className="dashboard-import-header">
<CloudArrowDown size={32} weight="light" />
<h2>Import from URL</h2>
<p>Import articles from external URLs using Firecrawl</p>
<p>Import articles directly to the database using Firecrawl</p>
</div>
<div className="dashboard-import-form">
@@ -3467,8 +4054,21 @@ function ImportURLSection({
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/article"
className="dashboard-import-input"
onKeyDown={(e) => {
if (e.key === "Enter" && url.trim() && !isLoading) {
handleImport();
}
}}
/>
</div>
<label className="dashboard-import-checkbox">
<input
type="checkbox"
checked={publishImmediately}
onChange={(e) => setPublishImmediately(e.target.checked)}
/>
<span>Publish immediately</span>
</label>
<button
className="dashboard-import-btn"
onClick={handleImport}
@@ -3476,27 +4076,42 @@ function ImportURLSection({
>
{isLoading ? (
<>
<ArrowClockwise size={16} className="spin" />
<SpinnerGap size={16} className="animate-spin" />
<span>Importing...</span>
</>
) : (
<>
<CloudArrowDown size={16} />
<span>Import</span>
<span>Import to Database</span>
</>
)}
</button>
</div>
{lastImported && (
<div className="dashboard-import-success">
<CheckCircle size={20} weight="fill" />
<div>
<strong>Successfully imported:</strong> {lastImported.title}
<br />
<Link to={`/${lastImported.slug}`} className="import-view-link">
View post
</Link>
</div>
</div>
)}
<div className="dashboard-import-info">
<h3>How it works</h3>
<ol>
<li>Enter the URL of an article you want to import</li>
<li>Firecrawl will scrape and convert it to markdown</li>
<li>A draft post will be created in content/blog/</li>
<li>Review, edit, and sync when ready</li>
<li>Firecrawl scrapes and converts it to markdown</li>
<li>Post is saved directly to the database</li>
<li>Edit and publish from the Posts section</li>
</ol>
<p className="note">Requires FIRECRAWL_API_KEY in .env.local</p>
<p className="note">
Requires FIRECRAWL_API_KEY in Convex environment variables
</p>
</div>
</div>
);

View File

@@ -3431,6 +3431,64 @@ body {
}
}
/* Search mode toggle (Keyword / Semantic) */
.search-mode-toggle {
display: flex;
gap: 6px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.search-mode-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-muted);
font-size: var(--font-size-sm);
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.search-mode-btn:hover {
background: var(--bg-hover);
color: var(--text-secondary);
}
.search-mode-btn.active {
background: var(--bg-primary);
border-color: var(--text-secondary);
color: var(--text-primary);
}
/* Search result meta (type + score) */
.search-result-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
.search-result-score {
font-size: var(--font-size-2xs);
color: var(--text-muted);
font-family: var(--font-family-mono);
}
/* Empty state hint for semantic search */
.search-modal-empty-hint {
font-size: var(--font-size-sm);
color: var(--text-muted);
margin-top: 8px;
}
/* Search input wrapper */
.search-modal-input-wrapper {
display: flex;
@@ -8842,8 +8900,8 @@ body {
.dashboard-list-row {
display: grid;
grid-template-columns: 1fr 120px 100px 100px;
gap: 1rem;
grid-template-columns: 1fr 110px 170px 110px;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border-color);
align-items: center;
@@ -8893,6 +8951,8 @@ body {
.dashboard-list-row .col-status {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.status-badge {
@@ -8914,6 +8974,27 @@ body {
color: #ca8a04;
}
.source-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: var(--font-size-2xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
white-space: nowrap;
}
.source-badge.dashboard {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.source-badge.sync {
background: rgba(107, 114, 128, 0.15);
color: var(--text-tertiary);
}
.dashboard-list-row .col-actions {
display: flex;
align-items: center;
@@ -9054,6 +9135,36 @@ body {
opacity: 0.9;
}
.dashboard-action-btn.success {
background: #22c55e;
color: #fff;
border-color: #22c55e;
}
.dashboard-action-btn.success:hover {
background: #16a34a;
border-color: #16a34a;
}
.dashboard-action-btn.success:disabled {
background: #86efac;
border-color: #86efac;
cursor: not-allowed;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.dashboard-editor-container {
display: flex;
flex: 1;
@@ -10148,6 +10259,53 @@ body {
font-style: italic;
}
.dashboard-import-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.dashboard-import-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.dashboard-import-success {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
margin-bottom: 1.5rem;
color: #22c55e;
}
.dashboard-import-success svg {
flex-shrink: 0;
margin-top: 2px;
}
.dashboard-import-success strong {
color: #16a34a;
}
.import-view-link {
color: #22c55e;
text-decoration: none;
font-size: var(--font-size-sm);
}
.import-view-link:hover {
text-decoration: underline;
}
/* Sync Section */
.dashboard-sync-section {
max-width: 800px;
@@ -10578,11 +10736,111 @@ body {
}
.dashboard-write-title {
display: flex;
align-items: center;
gap: 1rem;
font-size: var(--font-size-md);
font-weight: 600;
color: var(--text-primary);
}
.dashboard-editor-mode-toggles {
display: flex;
gap: 0;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
overflow: hidden;
}
.dashboard-editor-mode-toggles .dashboard-view-toggle {
border: none;
border-radius: 0;
border-right: 1px solid var(--border-color);
}
.dashboard-editor-mode-toggles .dashboard-view-toggle:last-child {
border-right: none;
}
/* Quill Editor Styles */
.dashboard-quill-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dashboard-quill-container .quill {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dashboard-quill-container .ql-container {
flex: 1;
overflow: auto;
font-family: var(--font-family-base);
font-size: var(--font-size-base);
}
.dashboard-quill-container .ql-editor {
min-height: 100%;
padding: 1rem;
color: var(--text-primary);
background: var(--bg-secondary);
}
.dashboard-quill-container .ql-editor.ql-blank::before {
color: var(--text-tertiary);
font-style: normal;
}
.dashboard-quill-container .ql-toolbar {
background: var(--bg-primary);
border: none;
border-bottom: 1px solid var(--border-color);
padding: 0.5rem;
}
.dashboard-quill-container .ql-toolbar .ql-stroke {
stroke: var(--text-secondary);
}
.dashboard-quill-container .ql-toolbar .ql-fill {
fill: var(--text-secondary);
}
.dashboard-quill-container .ql-toolbar .ql-picker {
color: var(--text-secondary);
}
.dashboard-quill-container .ql-toolbar button:hover .ql-stroke,
.dashboard-quill-container .ql-toolbar button.ql-active .ql-stroke {
stroke: var(--text-primary);
}
.dashboard-quill-container .ql-toolbar button:hover .ql-fill,
.dashboard-quill-container .ql-toolbar button.ql-active .ql-fill {
fill: var(--text-primary);
}
.dashboard-quill-container .ql-toolbar .ql-picker-label:hover,
.dashboard-quill-container .ql-toolbar .ql-picker-label.ql-active {
color: var(--text-primary);
}
.dashboard-quill-container .ql-toolbar .ql-picker-options {
background: var(--bg-primary);
border-color: var(--border-color);
}
.dashboard-quill-container .ql-container {
border: none;
}
.dashboard-write-type-selector {
display: flex;
gap: 0.5rem;
@@ -11330,6 +11588,71 @@ body {
opacity: 0.9;
}
/* Delete confirmation modal */
.dashboard-modal-delete {
max-width: 420px;
}
.dashboard-modal-icon-warning {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.dashboard-modal-content {
padding: 0 1.5rem 1rem;
}
.dashboard-modal-message {
margin: 0 0 1rem;
color: var(--text-primary);
font-size: var(--font-size-base);
}
.dashboard-modal-item-name {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
color: var(--text-primary);
font-weight: 500;
margin-bottom: 1rem;
}
.dashboard-modal-item-name svg {
color: var(--text-secondary);
flex-shrink: 0;
}
.dashboard-modal-warning-text {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-tertiary);
line-height: 1.5;
}
.dashboard-modal-btn.danger {
display: flex;
align-items: center;
gap: 0.375rem;
background: #ef4444;
border: 1px solid #ef4444;
color: #fff;
}
.dashboard-modal-btn.danger:hover:not(:disabled) {
background: #dc2626;
border-color: #dc2626;
}
.dashboard-modal-btn.danger:disabled {
background: #fca5a5;
border-color: #fca5a5;
cursor: not-allowed;
}
/* Modal theme variations */
:root[data-theme="dark"] .dashboard-modal-backdrop {
background: rgba(0, 0, 0, 0.7);