From e5b22487ca25a6a044121320d9bd017d28a8ede1 Mon Sep 17 00:00:00 2001 From: Wayne Sutton Date: Wed, 17 Dec 2025 22:02:52 -0800 Subject: [PATCH] feat(search): add real-time full text search with Command+K modal using Convex search indexes and Phosphor Icons --- README.md | 16 ++ TASK.md | 6 +- changelog.md | 23 +- convex/_generated/api.d.ts | 2 + convex/schema.ts | 10 +- convex/search.ts | 159 ++++++++++++++ files.md | 6 +- package-lock.json | 14 ++ package.json | 1 + prds/howstatsworks.md | 115 ++++++++-- src/components/Layout.tsx | 48 ++++- src/components/SearchModal.tsx | 176 ++++++++++++++++ src/styles/global.css | 375 +++++++++++++++++++++++++++++++++ 13 files changed, 924 insertions(+), 27 deletions(-) create mode 100644 convex/search.ts create mode 100644 src/components/SearchModal.tsx diff --git a/README.md b/README.md index 8c5411b..89ec86f 100644 --- a/README.md +++ b/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: diff --git a/TASK.md b/TASK.md index e194cd0..8749f97 100644 --- a/TASK.md +++ b/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 diff --git a/changelog.md b/changelog.md index 20bca71..be78570 100644 --- a/changelog.md +++ b/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 diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9e72216..623a29c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; }>; diff --git a/convex/schema.ts b/convex/schema.ts index dd061cc..a731434 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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({ diff --git a/convex/search.ts b/convex/search.ts new file mode 100644 index 0000000..de39933 --- /dev/null +++ b/convex/search.ts @@ -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(); + 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(); + 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; +} + diff --git a/files.md b/files.md index ae7c7ce..f6a54ae 100644 --- a/files.md +++ b/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 | diff --git a/package-lock.json b/package-lock.json index c77f37d..0ab66e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a19376f..bdbdbf1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prds/howstatsworks.md b/prds/howstatsworks.md index 08664b1..b67613e 100644 --- a/prds/howstatsworks.md +++ b/prds/howstatsworks.md @@ -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(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) diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 26a85f5..01e4b03 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 (
- {/* Top navigation bar with page links and theme toggle */} + {/* Top navigation bar with page links, search, and theme toggle */}
{/* Page navigation links (optional pages like About, Projects, Contact) */} {pages && pages.length > 0 && ( @@ -30,12 +61,25 @@ export default function Layout({ children }: LayoutProps) { ))} )} + {/* Search button with icon */} + {/* Theme toggle */}
+
{children}
+ + {/* Search modal */} +
); } diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx new file mode 100644 index 0000000..0259974 --- /dev/null +++ b/src/components/SearchModal.tsx @@ -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(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 ( +
+
+ {/* Search input */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search posts and pages..." + className="search-modal-input" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + /> + +
+ + {/* Search results */} +
+ {searchQuery.trim() === "" ? ( +
+

Type to search posts and pages

+
+ + Navigate + + + Select + + + Esc Close + +
+
+ ) : results === undefined ? ( +
Searching...
+ ) : results.length === 0 ? ( +
+ No results found for "{searchQuery}" +
+ ) : ( +
    + {results.map((result, index) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Footer with keyboard hints */} + {results && results.length > 0 && ( +
+ + to select + + + to navigate + +
+ )} +
+
+ ); +} + diff --git a/src/styles/global.css b/src/styles/global.css index a83248d..713b3b5 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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; + } +}