Skip to content

Commit 5c70d9e

Browse files
committed
feat: add expandable search bar to examples gallery
- Search across prompts, titles, IDs, and tags
1 parent ed5e109 commit 5c70d9e

File tree

2 files changed

+173
-18
lines changed

2 files changed

+173
-18
lines changed

front-end/components/app-grid.tsx

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import * as React from "react";
44
import type { CodeExample } from "@/lib/code-examples";
55
import { AppCard } from "./app-card";
66
import { AppModal } from "./app-modal";
7+
import { SearchBar } from "./search-bar";
78

8-
/* ---------- Animated track ---------- */
99
const Track = React.memo(function Track({
1010
apps,
1111
onOpen,
@@ -24,7 +24,6 @@ const Track = React.memo(function Track({
2424
);
2525
});
2626

27-
/* ---------- Auto-scrolling row ---------- */
2827
const AutoScrollerRow = React.memo(function AutoScrollerRow({
2928
apps,
3029
reverse = false,
@@ -44,8 +43,7 @@ const AutoScrollerRow = React.memo(function AutoScrollerRow({
4443
reverse ? "animate-marquee-reverse" : "animate-marquee",
4544
"[animation-duration:var(--marquee-duration)]",
4645
].join(" ")}
47-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
48-
style={{ ["--marquee-duration" as any]: `${duration}s` }}
46+
style={{ "--marquee-duration": `${duration}s` } as React.CSSProperties}
4947
>
5048
<Track apps={apps} onOpen={onOpen} />
5149
<Track apps={apps} onOpen={onOpen} aria-hidden />
@@ -57,6 +55,7 @@ const AutoScrollerRow = React.memo(function AutoScrollerRow({
5755
export function AppGrid({ apps }: { apps: CodeExample[] }) {
5856
const [open, setOpen] = React.useState(false);
5957
const [active, setActive] = React.useState<CodeExample | null>(null);
58+
const [searchQuery, setSearchQuery] = React.useState("");
6059

6160
const onOpen = React.useCallback((app: CodeExample) => {
6261
setActive(app);
@@ -70,30 +69,72 @@ export function AppGrid({ apps }: { apps: CodeExample[] }) {
7069
} catch {}
7170
}, [active]);
7271

72+
const filteredApps = React.useMemo(() => {
73+
if (!searchQuery.trim()) return apps;
74+
75+
const query = searchQuery.toLowerCase().trim();
76+
return apps.filter(app => {
77+
const searchableText = [
78+
app.title,
79+
app.prompt,
80+
app.id,
81+
...(app.tags || [])
82+
].join(' ').toLowerCase();
83+
84+
return searchableText.includes(query);
85+
});
86+
}, [apps, searchQuery]);
87+
7388
const buckets = React.useMemo(() => {
89+
if (searchQuery) return [];
90+
7491
const ROWS = Math.min(8, Math.max(3, Math.ceil(apps.length / 8)));
7592
const rows: CodeExample[][] = Array.from({ length: ROWS }, () => []);
76-
apps.forEach((app, i) => rows[i % ROWS].push(app)); // deterministic
93+
apps.forEach((app, i) => rows[i % ROWS].push(app));
7794
return rows;
78-
}, [apps]);
95+
}, [apps, searchQuery]);
7996

8097
return (
8198
<>
99+
<div className="fixed top-4 right-4 z-50">
100+
<SearchBar onSearch={setSearchQuery} />
101+
</div>
102+
82103
<div className="min-h-screen overflow-y-auto pt-4">
83-
<div className="full-bleed flex flex-col gap-y-4">
84-
{buckets.map((row, i) => (
85-
<AutoScrollerRow
86-
key={i}
87-
apps={row.length ? row : apps}
88-
reverse={i % 2 === 1}
89-
onOpen={onOpen}
90-
duration={18 + ((i * 2) % 8)}
91-
/>
92-
))}
93-
</div>
104+
{searchQuery && (
105+
<div className="text-center mb-4 px-4">
106+
<p className="text-gray-600">
107+
{filteredApps.length > 0
108+
? `Found ${filteredApps.length} example${filteredApps.length !== 1 ? 's' : ''} matching "${searchQuery}"`
109+
: `No examples found for "${searchQuery}"`
110+
}
111+
</p>
112+
</div>
113+
)}
114+
115+
{searchQuery ? (
116+
filteredApps.length > 0 && (
117+
<div className="px-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 max-w-7xl mx-auto">
118+
{filteredApps.map((app) => (
119+
<AppCard key={app.id} app={app} onOpen={onOpen} />
120+
))}
121+
</div>
122+
)
123+
) : (
124+
<div className="full-bleed flex flex-col gap-y-4">
125+
{buckets.map((row, i) => (
126+
<AutoScrollerRow
127+
key={i}
128+
apps={row}
129+
reverse={i % 2 === 1}
130+
onOpen={onOpen}
131+
duration={18 + ((i * 2) % 8)}
132+
/>
133+
))}
134+
</div>
135+
)}
94136
</div>
95137

96-
{/* Modal; background rows keep animating */}
97138
<AppModal
98139
active={active}
99140
open={open}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { Search, X } from "lucide-react";
5+
6+
interface SearchBarProps {
7+
onSearch: (query: string) => void;
8+
placeholder?: string;
9+
}
10+
11+
export function SearchBar({ onSearch, placeholder = "Search examples..." }: SearchBarProps) {
12+
const [isExpanded, setIsExpanded] = React.useState(false);
13+
const [query, setQuery] = React.useState("");
14+
const inputRef = React.useRef<HTMLInputElement>(null);
15+
const debounceRef = React.useRef<NodeJS.Timeout | null>(null);
16+
17+
const handleToggle = React.useCallback(() => {
18+
if (isExpanded) {
19+
if (query) {
20+
setQuery("");
21+
onSearch("");
22+
}
23+
setIsExpanded(false);
24+
} else {
25+
setIsExpanded(true);
26+
}
27+
}, [isExpanded, query, onSearch]);
28+
29+
const handleClear = React.useCallback(() => {
30+
setQuery("");
31+
onSearch("");
32+
inputRef.current?.focus();
33+
}, [onSearch]);
34+
35+
React.useEffect(() => {
36+
if (isExpanded && inputRef.current) {
37+
inputRef.current.focus();
38+
}
39+
}, [isExpanded]);
40+
41+
React.useEffect(() => {
42+
if (debounceRef.current) {
43+
clearTimeout(debounceRef.current);
44+
}
45+
46+
debounceRef.current = setTimeout(() => {
47+
onSearch(query);
48+
}, 300);
49+
50+
return () => {
51+
if (debounceRef.current) {
52+
clearTimeout(debounceRef.current);
53+
}
54+
};
55+
}, [query, onSearch]);
56+
57+
const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
58+
if (e.key === "Escape") {
59+
e.preventDefault();
60+
if (query) {
61+
setQuery("");
62+
onSearch("");
63+
} else {
64+
setIsExpanded(false);
65+
}
66+
}
67+
}, [query, onSearch]);
68+
69+
return (
70+
<div className="relative flex items-center justify-center">
71+
<div
72+
className={`
73+
flex items-center gap-2 transition-all duration-300 ease-in-out
74+
${isExpanded
75+
? "w-80 bg-white/95 backdrop-blur-sm border border-gray-200 rounded-full px-4 py-2 shadow-lg"
76+
: "w-auto"
77+
}
78+
`}
79+
>
80+
<button
81+
onClick={isExpanded && query ? handleClear : handleToggle}
82+
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
83+
aria-label={isExpanded && query ? "Clear search" : isExpanded ? "Close search" : "Open search"}
84+
>
85+
{isExpanded && query ? (
86+
<X className="w-5 h-5 text-gray-600" />
87+
) : (
88+
<Search className="w-5 h-5 text-gray-600" />
89+
)}
90+
</button>
91+
92+
{isExpanded && (
93+
<input
94+
ref={inputRef}
95+
type="text"
96+
value={query}
97+
onChange={(e) => setQuery(e.target.value)}
98+
onKeyDown={handleKeyDown}
99+
placeholder={placeholder}
100+
className="flex-1 bg-transparent outline-none text-gray-800 placeholder-gray-500"
101+
autoComplete="off"
102+
spellCheck={false}
103+
/>
104+
)}
105+
</div>
106+
107+
{!isExpanded && (
108+
<span className="ml-2 text-sm text-gray-600 hidden sm:inline">
109+
Search
110+
</span>
111+
)}
112+
</div>
113+
);
114+
}

0 commit comments

Comments
 (0)