mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user