diff --git a/front-end/components/app-grid.tsx b/front-end/components/app-grid.tsx index 849809b..2a8f072 100644 --- a/front-end/components/app-grid.tsx +++ b/front-end/components/app-grid.tsx @@ -4,8 +4,8 @@ import * as React from "react"; import type { CodeExample } from "@/lib/code-examples"; import { AppCard } from "./app-card"; import { AppModal } from "./app-modal"; +import { SearchBar } from "./search-bar"; -/* ---------- Animated track ---------- */ const Track = React.memo(function Track({ apps, onOpen, @@ -24,7 +24,6 @@ const Track = React.memo(function Track({ ); }); -/* ---------- Auto-scrolling row ---------- */ const AutoScrollerRow = React.memo(function AutoScrollerRow({ apps, reverse = false, @@ -44,8 +43,7 @@ const AutoScrollerRow = React.memo(function AutoScrollerRow({ reverse ? "animate-marquee-reverse" : "animate-marquee", "[animation-duration:var(--marquee-duration)]", ].join(" ")} - // eslint-disable-next-line @typescript-eslint/no-explicit-any - style={{ ["--marquee-duration" as any]: `${duration}s` }} + style={{ "--marquee-duration": `${duration}s` } as React.CSSProperties} > @@ -57,6 +55,7 @@ const AutoScrollerRow = React.memo(function AutoScrollerRow({ export function AppGrid({ apps }: { apps: CodeExample[] }) { const [open, setOpen] = React.useState(false); const [active, setActive] = React.useState(null); + const [searchQuery, setSearchQuery] = React.useState(""); const onOpen = React.useCallback((app: CodeExample) => { setActive(app); @@ -70,30 +69,72 @@ export function AppGrid({ apps }: { apps: CodeExample[] }) { } catch {} }, [active]); + const filteredApps = React.useMemo(() => { + if (!searchQuery.trim()) return apps; + + const query = searchQuery.toLowerCase().trim(); + return apps.filter(app => { + const searchableText = [ + app.title, + app.prompt, + app.id, + ...(app.tags || []) + ].join(' ').toLowerCase(); + + return searchableText.includes(query); + }); + }, [apps, searchQuery]); + const buckets = React.useMemo(() => { + if (searchQuery) return []; + const ROWS = Math.min(8, Math.max(3, Math.ceil(apps.length / 8))); const rows: CodeExample[][] = Array.from({ length: ROWS }, () => []); - apps.forEach((app, i) => rows[i % ROWS].push(app)); // deterministic + apps.forEach((app, i) => rows[i % ROWS].push(app)); return rows; - }, [apps]); + }, [apps, searchQuery]); return ( <> +
+ +
+
-
- {buckets.map((row, i) => ( - - ))} -
+ {searchQuery && ( +
+

+ {filteredApps.length > 0 + ? `Found ${filteredApps.length} example${filteredApps.length !== 1 ? 's' : ''} matching "${searchQuery}"` + : `No examples found for "${searchQuery}"` + } +

+
+ )} + + {searchQuery ? ( + filteredApps.length > 0 && ( +
+ {filteredApps.map((app) => ( + + ))} +
+ ) + ) : ( +
+ {buckets.map((row, i) => ( + + ))} +
+ )}
- {/* Modal; background rows keep animating */} void; + placeholder?: string; +} + +export function SearchBar({ onSearch, placeholder = "Search examples..." }: SearchBarProps) { + const [isExpanded, setIsExpanded] = React.useState(false); + const [query, setQuery] = React.useState(""); + const containerRef = React.useRef(null); + const inputRef = React.useRef(null); + const debounceRef = React.useRef(null); + + const handleToggle = React.useCallback(() => { + if (isExpanded) { + if (query) { + setQuery(""); + onSearch(""); + } + setIsExpanded(false); + } else { + setIsExpanded(true); + } + }, [isExpanded, query, onSearch]); + + const handleClear = React.useCallback(() => { + setQuery(""); + onSearch(""); + inputRef.current?.focus(); + }, [onSearch]); + + const handleClose = React.useCallback(() => { + if (!query) { + setIsExpanded(false); + } + }, [query]); + + React.useEffect(() => { + if (isExpanded && inputRef.current) { + inputRef.current.focus(); + } + }, [isExpanded]); + + React.useEffect(() => { + if (!isExpanded) return; + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + handleClose(); + } + }; + + const handleScroll = () => { + handleClose(); + }; + + document.addEventListener("mousedown", handleClickOutside); + window.addEventListener("scroll", handleScroll, true); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + window.removeEventListener("scroll", handleScroll, true); + }; + }, [isExpanded, handleClose]); + + React.useEffect(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + onSearch(query); + }, 300); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [query, onSearch]); + + const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + if (query) { + setQuery(""); + onSearch(""); + } else { + setIsExpanded(false); + } + } + }, [query, onSearch]); + + return ( +
+
+ + + {isExpanded && ( + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="flex-1 bg-transparent outline-none text-gray-800 placeholder-gray-500" + autoComplete="off" + spellCheck={false} + /> + )} +
+ + {!isExpanded && ( + + Search + + )} +
+ ); +} \ No newline at end of file