mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feat(search): add real-time full text search with Command+K modal using Convex search indexes and Phosphor Icons
This commit is contained in:
16
README.md
16
README.md
@@ -12,6 +12,7 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
|
||||
- Real-time data with Convex
|
||||
- Fully responsive design
|
||||
- Real-time analytics at `/stats`
|
||||
- Full text search with Command+K shortcut
|
||||
|
||||
### SEO and Discovery
|
||||
|
||||
@@ -263,8 +264,23 @@ markdown-site/
|
||||
- react-syntax-highlighter
|
||||
- date-fns
|
||||
- lucide-react
|
||||
- @phosphor-icons/react
|
||||
- Netlify
|
||||
|
||||
## Search
|
||||
|
||||
Press `Command+K` (Mac) or `Ctrl+K` (Windows/Linux) to open the search modal. The search uses Convex full text search to find posts and pages by title and content.
|
||||
|
||||
Features:
|
||||
|
||||
- Real-time results as you type
|
||||
- Keyboard navigation (arrow keys, Enter, Escape)
|
||||
- Result snippets with context around matches
|
||||
- Distinguishes between posts and pages
|
||||
- Works with all four themes
|
||||
|
||||
The search icon appears in the top navigation bar next to the theme toggle.
|
||||
|
||||
## Real-time Stats
|
||||
|
||||
The `/stats` page shows real-time analytics powered by Convex:
|
||||
|
||||
6
TASK.md
6
TASK.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## Current Status
|
||||
|
||||
v1.2.0 ready for deployment. Build passes. TypeScript verified.
|
||||
v1.3.0 ready for deployment. Build passes. TypeScript verified.
|
||||
|
||||
## Completed
|
||||
|
||||
@@ -33,6 +33,9 @@ v1.2.0 ready for deployment. Build passes. TypeScript verified.
|
||||
- [x] Active session heartbeat system
|
||||
- [x] Cron job for stale session cleanup
|
||||
- [x] Stats link in homepage footer
|
||||
- [x] Real-time search with Command+K shortcut
|
||||
- [x] Search modal with keyboard navigation
|
||||
- [x] Full text search indexes for posts and pages
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
@@ -43,7 +46,6 @@ v1.2.0 ready for deployment. Build passes. TypeScript verified.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Search functionality
|
||||
- [ ] Related posts suggestions
|
||||
- [ ] Newsletter signup
|
||||
- [ ] Comments system
|
||||
|
||||
23
changelog.md
23
changelog.md
@@ -4,7 +4,28 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [1.2.0] - 2025-12-14
|
||||
## [1.3.0] - 2025-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- Real-time search with Command+K keyboard shortcut
|
||||
- Search icon in top nav using Phosphor Icons
|
||||
- Modal with keyboard navigation (arrow keys, Enter, Escape)
|
||||
- Full text search across posts and pages using Convex search indexes
|
||||
- Result snippets with context around search matches
|
||||
- Distinguishes between posts and pages with type badges
|
||||
- Search indexes for pages table (title and content)
|
||||
- New `@phosphor-icons/react` dependency for search icon
|
||||
|
||||
### Technical
|
||||
|
||||
- Uses Convex full text search with reactive queries
|
||||
- Deduplicates results from title and content searches
|
||||
- Sorts results with title matches first
|
||||
- Mobile responsive modal design
|
||||
- All four themes supported (dark, light, tan, cloud)
|
||||
|
||||
## [1.2.0] - 2025-12-15
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -13,6 +13,7 @@ import type * as http from "../http.js";
|
||||
import type * as pages from "../pages.js";
|
||||
import type * as posts from "../posts.js";
|
||||
import type * as rss from "../rss.js";
|
||||
import type * as search from "../search.js";
|
||||
import type * as stats from "../stats.js";
|
||||
|
||||
import type {
|
||||
@@ -27,6 +28,7 @@ declare const fullApi: ApiFromModules<{
|
||||
pages: typeof pages;
|
||||
posts: typeof posts;
|
||||
rss: typeof rss;
|
||||
search: typeof search;
|
||||
stats: typeof stats;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -37,7 +37,15 @@ export default defineSchema({
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_published", ["published"]),
|
||||
.index("by_published", ["published"])
|
||||
.searchIndex("search_content", {
|
||||
searchField: "content",
|
||||
filterFields: ["published"],
|
||||
})
|
||||
.searchIndex("search_title", {
|
||||
searchField: "title",
|
||||
filterFields: ["published"],
|
||||
}),
|
||||
|
||||
// View counts for analytics
|
||||
viewCounts: defineTable({
|
||||
|
||||
159
convex/search.ts
Normal file
159
convex/search.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
// Search result type for both posts and pages
|
||||
const searchResultValidator = v.object({
|
||||
_id: v.string(),
|
||||
type: v.union(v.literal("post"), v.literal("page")),
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
snippet: v.string(),
|
||||
});
|
||||
|
||||
// Search across posts and pages
|
||||
export const search = query({
|
||||
args: {
|
||||
query: v.string(),
|
||||
},
|
||||
returns: v.array(searchResultValidator),
|
||||
handler: async (ctx, args) => {
|
||||
// Return empty results for empty queries
|
||||
if (!args.query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
_id: string;
|
||||
type: "post" | "page";
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
snippet: string;
|
||||
}> = [];
|
||||
|
||||
// Search posts by title
|
||||
const postsByTitle = await ctx.db
|
||||
.query("posts")
|
||||
.withSearchIndex("search_title", (q) =>
|
||||
q.search("title", args.query).eq("published", true)
|
||||
)
|
||||
.take(10);
|
||||
|
||||
// Search posts by content
|
||||
const postsByContent = await ctx.db
|
||||
.query("posts")
|
||||
.withSearchIndex("search_content", (q) =>
|
||||
q.search("content", args.query).eq("published", true)
|
||||
)
|
||||
.take(10);
|
||||
|
||||
// Search pages by title
|
||||
const pagesByTitle = await ctx.db
|
||||
.query("pages")
|
||||
.withSearchIndex("search_title", (q) =>
|
||||
q.search("title", args.query).eq("published", true)
|
||||
)
|
||||
.take(10);
|
||||
|
||||
// Search pages by content
|
||||
const pagesByContent = await ctx.db
|
||||
.query("pages")
|
||||
.withSearchIndex("search_content", (q) =>
|
||||
q.search("content", args.query).eq("published", true)
|
||||
)
|
||||
.take(10);
|
||||
|
||||
// Deduplicate and process post results
|
||||
const seenPostIds = new Set<string>();
|
||||
for (const post of [...postsByTitle, ...postsByContent]) {
|
||||
if (seenPostIds.has(post._id)) continue;
|
||||
seenPostIds.add(post._id);
|
||||
|
||||
// Create snippet from content
|
||||
const snippet = createSnippet(post.content, args.query, 120);
|
||||
|
||||
results.push({
|
||||
_id: post._id,
|
||||
type: "post" as const,
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicate and process page results
|
||||
const seenPageIds = new Set<string>();
|
||||
for (const page of [...pagesByTitle, ...pagesByContent]) {
|
||||
if (seenPageIds.has(page._id)) continue;
|
||||
seenPageIds.add(page._id);
|
||||
|
||||
// Create snippet from content
|
||||
const snippet = createSnippet(page.content, args.query, 120);
|
||||
|
||||
results.push({
|
||||
_id: page._id,
|
||||
type: "page" as const,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort results: title matches first, then by relevance
|
||||
const queryLower = args.query.toLowerCase();
|
||||
results.sort((a, b) => {
|
||||
const aInTitle = a.title.toLowerCase().includes(queryLower);
|
||||
const bInTitle = b.title.toLowerCase().includes(queryLower);
|
||||
if (aInTitle && !bInTitle) return -1;
|
||||
if (!aInTitle && bInTitle) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Limit to top 15 results
|
||||
return results.slice(0, 15);
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to create a snippet around the search term
|
||||
function createSnippet(
|
||||
content: string,
|
||||
searchTerm: string,
|
||||
maxLength: number
|
||||
): string {
|
||||
// Remove markdown syntax for cleaner snippets
|
||||
const cleanContent = content
|
||||
.replace(/#{1,6}\s/g, "") // Headers
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1") // Bold
|
||||
.replace(/\*([^*]+)\*/g, "$1") // Italic
|
||||
.replace(/`([^`]+)`/g, "$1") // Inline code
|
||||
.replace(/```[\s\S]*?```/g, "") // Code blocks
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Links
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "") // Images
|
||||
.replace(/\n+/g, " ") // Newlines to spaces
|
||||
.replace(/\s+/g, " ") // Multiple spaces to single
|
||||
.trim();
|
||||
|
||||
const lowerContent = cleanContent.toLowerCase();
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
const index = lowerContent.indexOf(lowerSearchTerm);
|
||||
|
||||
if (index === -1) {
|
||||
// Term not found, return beginning of content
|
||||
return cleanContent.slice(0, maxLength) + (cleanContent.length > maxLength ? "..." : "");
|
||||
}
|
||||
|
||||
// Calculate start position to center the search term
|
||||
const start = Math.max(0, index - Math.floor(maxLength / 3));
|
||||
const end = Math.min(cleanContent.length, start + maxLength);
|
||||
|
||||
let snippet = cleanContent.slice(start, end);
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (start > 0) snippet = "..." + snippet;
|
||||
if (end < cleanContent.length) snippet = snippet + "...";
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
6
files.md
6
files.md
@@ -38,11 +38,12 @@ A brief description of each file in the codebase.
|
||||
|
||||
| File | Description |
|
||||
| ---------------------- | ---------------------------------------------------------- |
|
||||
| `Layout.tsx` | Page wrapper with theme toggle container |
|
||||
| `Layout.tsx` | Page wrapper with search button and theme toggle |
|
||||
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
|
||||
| `PostList.tsx` | Year-grouped blog post list |
|
||||
| `BlogPost.tsx` | Markdown renderer with syntax highlighting |
|
||||
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude, Cursor, VS Code) |
|
||||
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude) |
|
||||
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
|
||||
|
||||
### Context (`src/context/`)
|
||||
|
||||
@@ -69,6 +70,7 @@ A brief description of each file in the codebase.
|
||||
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions) |
|
||||
| `posts.ts` | Queries and mutations for blog posts, view counts |
|
||||
| `pages.ts` | Queries and mutations for static pages |
|
||||
| `search.ts` | Full text search queries across posts and pages |
|
||||
| `stats.ts` | Real-time stats queries, page view recording, session heartbeat |
|
||||
| `crons.ts` | Cron job for stale session cleanup |
|
||||
| `http.ts` | HTTP endpoints: sitemap, API, Open Graph metadata |
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "markdown-site",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"convex": "^1.17.4",
|
||||
"date-fns": "^3.3.1",
|
||||
@@ -1003,6 +1004,19 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@phosphor-icons/react": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
|
||||
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8",
|
||||
"react-dom": ">= 16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-icons": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"deploy:prod": "npx convex deploy && npm run sync:prod"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"convex": "^1.17.4",
|
||||
"date-fns": "^3.3.1",
|
||||
|
||||
@@ -85,17 +85,62 @@ useEffect(() => {
|
||||
|
||||
Sends a ping every 30 seconds while the page is open. This powers the "Active Now" count.
|
||||
|
||||
Uses refs to prevent duplicate calls and avoid write conflicts:
|
||||
|
||||
```typescript
|
||||
const HEARTBEAT_INTERVAL_MS = 30 * 1000;
|
||||
const HEARTBEAT_DEBOUNCE_MS = 5 * 1000;
|
||||
|
||||
// Track heartbeat state to prevent duplicate calls
|
||||
const isHeartbeatPending = useRef(false);
|
||||
const lastHeartbeatTime = useRef(0);
|
||||
const lastHeartbeatPath = useRef<string | null>(null);
|
||||
|
||||
const sendHeartbeat = useCallback(
|
||||
async (path: string) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (!sessionId) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Skip if heartbeat is already pending
|
||||
if (isHeartbeatPending.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if same path and sent recently (debounce)
|
||||
if (
|
||||
lastHeartbeatPath.current === path &&
|
||||
now - lastHeartbeatTime.current < HEARTBEAT_DEBOUNCE_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isHeartbeatPending.current = true;
|
||||
lastHeartbeatTime.current = now;
|
||||
lastHeartbeatPath.current = path;
|
||||
|
||||
try {
|
||||
await heartbeatMutation({ sessionId, currentPath: path });
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
isHeartbeatPending.current = false;
|
||||
}
|
||||
},
|
||||
[heartbeatMutation],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const sendHeartbeat = () => {
|
||||
heartbeat({ sessionId, currentPath: path });
|
||||
};
|
||||
sendHeartbeat();
|
||||
const intervalId = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
|
||||
const path = location.pathname;
|
||||
sendHeartbeat(path);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
sendHeartbeat(path);
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [location.pathname, heartbeat]);
|
||||
}, [location.pathname, sendHeartbeat]);
|
||||
```
|
||||
|
||||
## Backend mutations
|
||||
@@ -143,9 +188,11 @@ export const recordPageView = mutation({
|
||||
|
||||
### heartbeat
|
||||
|
||||
Creates or updates a session record. Uses indexed lookup for upsert.
|
||||
Creates or updates a session record. Uses indexed lookup for upsert with a 10-second dedup window to prevent write conflicts.
|
||||
|
||||
```typescript
|
||||
const HEARTBEAT_DEDUP_MS = 10 * 1000;
|
||||
|
||||
export const heartbeat = mutation({
|
||||
args: {
|
||||
sessionId: v.string(),
|
||||
@@ -153,24 +200,35 @@ export const heartbeat = mutation({
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
|
||||
const existingSession = await ctx.db
|
||||
.query("activeSessions")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.first();
|
||||
|
||||
if (existingSession) {
|
||||
// Early return if same path and recently updated (idempotent)
|
||||
if (
|
||||
existingSession.currentPath === args.currentPath &&
|
||||
now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await ctx.db.patch(existingSession._id, {
|
||||
currentPath: args.currentPath,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("activeSessions", {
|
||||
sessionId: args.sessionId,
|
||||
currentPath: args.currentPath,
|
||||
lastSeen: Date.now(),
|
||||
lastSeen: now,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await ctx.db.insert("activeSessions", {
|
||||
sessionId: args.sessionId,
|
||||
currentPath: args.currentPath,
|
||||
lastSeen: now,
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -261,11 +319,13 @@ No manual configuration required. Sync content, and stats track it.
|
||||
|
||||
## Configuration constants
|
||||
|
||||
| Constant | Value | Location |
|
||||
|----------|-------|----------|
|
||||
| DEDUP_WINDOW_MS | 30 minutes | convex/stats.ts |
|
||||
| SESSION_TIMEOUT_MS | 2 minutes | convex/stats.ts |
|
||||
| HEARTBEAT_INTERVAL_MS | 30 seconds | src/hooks/usePageTracking.ts |
|
||||
| Constant | Value | Location | Purpose |
|
||||
|----------|-------|----------|---------|
|
||||
| DEDUP_WINDOW_MS | 30 minutes | convex/stats.ts | Page view deduplication |
|
||||
| SESSION_TIMEOUT_MS | 2 minutes | convex/stats.ts | Active session expiry |
|
||||
| HEARTBEAT_DEDUP_MS | 10 seconds | convex/stats.ts | Backend idempotency window |
|
||||
| HEARTBEAT_INTERVAL_MS | 30 seconds | src/hooks/usePageTracking.ts | Client heartbeat frequency |
|
||||
| HEARTBEAT_DEBOUNCE_MS | 5 seconds | src/hooks/usePageTracking.ts | Frontend debounce window |
|
||||
|
||||
## Files involved
|
||||
|
||||
@@ -277,8 +337,25 @@ No manual configuration required. Sync content, and stats track it.
|
||||
| `src/hooks/usePageTracking.ts` | Client-side tracking hook |
|
||||
| `src/pages/Stats.tsx` | Stats page UI |
|
||||
|
||||
## Write conflict prevention
|
||||
|
||||
The stats system uses several patterns to avoid write conflicts in the `activeSessions` table:
|
||||
|
||||
**Backend (convex/stats.ts):**
|
||||
- 10-second dedup window: skips updates if session was recently updated with same path
|
||||
- Indexed queries: uses `by_sessionId` index for efficient lookups
|
||||
- Early returns: mutation is idempotent and safe to call multiple times
|
||||
|
||||
**Frontend (src/hooks/usePageTracking.ts):**
|
||||
- 5-second debounce: prevents rapid duplicate calls from the same tab
|
||||
- Pending state ref: blocks overlapping async calls
|
||||
- Path tracking ref: skips redundant heartbeats for same path
|
||||
|
||||
See `prds/howtoavoidwriteconflicts.md` for the full implementation details.
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Convex event records pattern](https://docs.convex.dev/understanding/best-practices/)
|
||||
- [Preventing write conflicts](https://docs.convex.dev/error#1)
|
||||
- [Optimistic concurrency control](https://docs.convex.dev/database/advanced/occ)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
import SearchModal from "./SearchModal";
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -11,10 +13,39 @@ interface LayoutProps {
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
// Fetch published pages for navigation
|
||||
const pages = useQuery(api.pages.getAllPages);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
|
||||
// Open search modal
|
||||
const openSearch = useCallback(() => {
|
||||
setIsSearchOpen(true);
|
||||
}, []);
|
||||
|
||||
// Close search modal
|
||||
const closeSearch = useCallback(() => {
|
||||
setIsSearchOpen(false);
|
||||
}, []);
|
||||
|
||||
// Handle Command+K / Ctrl+K keyboard shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Command+K on Mac, Ctrl+K on Windows/Linux
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setIsSearchOpen((prev) => !prev);
|
||||
}
|
||||
// Also close on Escape
|
||||
if (e.key === "Escape" && isSearchOpen) {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isSearchOpen]);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
{/* Top navigation bar with page links and theme toggle */}
|
||||
{/* Top navigation bar with page links, search, and theme toggle */}
|
||||
<div className="top-nav">
|
||||
{/* Page navigation links (optional pages like About, Projects, Contact) */}
|
||||
{pages && pages.length > 0 && (
|
||||
@@ -30,12 +61,25 @@ export default function Layout({ children }: LayoutProps) {
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
{/* Search button with icon */}
|
||||
<button
|
||||
onClick={openSearch}
|
||||
className="search-button"
|
||||
aria-label="Search (⌘K)"
|
||||
title="Search (⌘K)"
|
||||
>
|
||||
<MagnifyingGlass size={18} weight="bold" />
|
||||
</button>
|
||||
{/* Theme toggle */}
|
||||
<div className="theme-toggle-container">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="main-content">{children}</main>
|
||||
|
||||
{/* Search modal */}
|
||||
<SearchModal isOpen={isSearchOpen} onClose={closeSearch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
176
src/components/SearchModal.tsx
Normal file
176
src/components/SearchModal.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { MagnifyingGlass, X, FileText, Article, ArrowRight } from "@phosphor-icons/react";
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch search results from Convex
|
||||
const results = useQuery(
|
||||
api.search.search,
|
||||
searchQuery.trim() ? { query: searchQuery } : "skip"
|
||||
);
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
setSearchQuery("");
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Reset selection when results change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [results]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!results || results.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % results.length);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (results[selectedIndex]) {
|
||||
navigate(`/${results[selectedIndex].slug}`);
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[results, selectedIndex, navigate, onClose]
|
||||
);
|
||||
|
||||
// Handle clicking on a result
|
||||
const handleResultClick = (slug: string) => {
|
||||
navigate(`/${slug}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="search-modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="search-modal">
|
||||
{/* Search input */}
|
||||
<div className="search-modal-input-wrapper">
|
||||
<MagnifyingGlass size={20} className="search-modal-icon" weight="bold" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search posts and pages..."
|
||||
className="search-modal-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button onClick={onClose} className="search-modal-close" aria-label="Close search">
|
||||
<X size={18} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
<div className="search-modal-results">
|
||||
{searchQuery.trim() === "" ? (
|
||||
<div className="search-modal-hint">
|
||||
<p>Type to search posts and pages</p>
|
||||
<div className="search-modal-shortcuts">
|
||||
<span className="search-shortcut">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> Navigate
|
||||
</span>
|
||||
<span className="search-shortcut">
|
||||
<kbd>↵</kbd> Select
|
||||
</span>
|
||||
<span className="search-shortcut">
|
||||
<kbd>Esc</kbd> Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : results === undefined ? (
|
||||
<div className="search-modal-loading">Searching...</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="search-modal-empty">
|
||||
No results found for "{searchQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<ul className="search-results-list">
|
||||
{results.map((result, index) => (
|
||||
<li key={result._id}>
|
||||
<button
|
||||
className={`search-result-item ${index === selectedIndex ? "selected" : ""}`}
|
||||
onClick={() => handleResultClick(result.slug)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="search-result-icon">
|
||||
{result.type === "post" ? (
|
||||
<Article size={20} weight="regular" />
|
||||
) : (
|
||||
<FileText size={20} weight="regular" />
|
||||
)}
|
||||
</div>
|
||||
<div className="search-result-content">
|
||||
<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>
|
||||
<ArrowRight size={16} className="search-result-arrow" weight="bold" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with keyboard hints */}
|
||||
{results && results.length > 0 && (
|
||||
<div className="search-modal-footer">
|
||||
<span className="search-footer-hint">
|
||||
<kbd>↵</kbd> to select
|
||||
</span>
|
||||
<span className="search-footer-hint">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> to navigate
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1137,3 +1137,378 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Search button in top nav */
|
||||
.search-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Search modal backdrop */
|
||||
.search-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
animation: backdropFadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes backdropFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Search modal container */
|
||||
.search-modal {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 70vh;
|
||||
margin: 0 16px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: modalSlideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Search input wrapper */
|
||||
.search-modal-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-modal-icon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-modal-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-modal-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.search-modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Search results container */
|
||||
.search-modal-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Search hint (empty state) */
|
||||
.search-modal-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-modal-hint p {
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-modal-shortcuts {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-shortcut kbd {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Loading and empty states */
|
||||
.search-modal-loading,
|
||||
.search-modal-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Search results list */
|
||||
.search-results-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-item.selected {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.search-result-icon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-result-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.search-result-snippet {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.search-result-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.search-result-arrow {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.search-result-item:hover .search-result-arrow,
|
||||
.search-result-item.selected .search-result-arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Search modal footer */
|
||||
.search-modal-footer {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.search-footer-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-footer-hint kbd {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments for search modal */
|
||||
:root[data-theme="dark"] .search-modal-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .search-modal {
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Tan theme adjustments for search modal */
|
||||
:root[data-theme="tan"] .search-modal {
|
||||
box-shadow: 0 16px 48px rgba(139, 115, 85, 0.15);
|
||||
}
|
||||
|
||||
/* Cloud theme adjustments for search modal */
|
||||
:root[data-theme="cloud"] .search-modal {
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Mobile responsive search modal */
|
||||
@media (max-width: 768px) {
|
||||
.search-modal-backdrop {
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.search-modal {
|
||||
max-height: 80vh;
|
||||
margin: 0 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.search-modal-input-wrapper {
|
||||
padding: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-modal-input {
|
||||
font-size: 16px; /* Prevent zoom on iOS */
|
||||
}
|
||||
|
||||
.search-modal-results {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-result-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-result-snippet {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-result-type {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.search-modal-shortcuts {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-modal-footer {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.search-modal-backdrop {
|
||||
padding-top: 5vh;
|
||||
}
|
||||
|
||||
.search-modal {
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.search-result-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user